diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 10949e2..96d9c94 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Cross-platform build +name: Cross-platform build (disposable signatures) on: workflow_dispatch: @@ -8,36 +8,50 @@ on: permissions: contents: read +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + jobs: build: - name: ${{ matrix.name }} + name: CI test bundle / ${{ matrix.platform }} strategy: fail-fast: false matrix: include: - - name: Linux x64 + - platform: linux-x86_64 os: ubuntu-22.04 - args: --bundles appimage,deb - - name: Windows x64 + target: x86_64-unknown-linux-gnu + bundles: appimage,deb + - platform: windows-x86_64 os: windows-2022 - args: --bundles nsis,msi - - name: macOS Apple Silicon + target: x86_64-pc-windows-msvc + bundles: nsis,msi + - platform: darwin-aarch64 os: macos-15 - args: --target aarch64-apple-darwin --bundles dmg - - name: macOS Intel + target: aarch64-apple-darwin + bundles: dmg,app + - platform: darwin-x86_64 os: macos-15-intel - args: --target x86_64-apple-darwin --bundles dmg - + target: x86_64-apple-darwin + bundles: dmg,app runs-on: ${{ matrix.os }} - + defaults: + run: + shell: bash steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: npm + - uses: actions/setup-python@v5 + with: + python-version: '3.12' - uses: dtolnay/rust-toolchain@stable - - name: Install Linux system dependencies + with: + targets: ${{ matrix.target }} + - name: Install Linux desktop dependencies if: runner.os == 'Linux' run: | sudo apt-get update @@ -45,29 +59,19 @@ jobs: - run: npm ci - run: npm test - run: cargo test --locked --manifest-path src-tauri/Cargo.toml - - run: npm run tauri:build -- ${{ matrix.args }} - - name: Upload Windows installers - if: runner.os == 'Windows' - uses: actions/upload-artifact@v4 + - run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml + - run: python -m unittest discover -s scripts -p 'release_test.py' -v + - name: Generate disposable CI key (never a production secret) + run: | + python scripts/release.py ci-key --directory "$RUNNER_TEMP/ci-updater" + python -c "import json,os; open(os.environ['GITHUB_ENV'],'a').write('RELEASE_VERSION='+json.load(open('package.json'))['version']+'\n')" + - name: Build packages and updater bundles + run: npm run tauri:build -- --ci --target '${{ matrix.target }}' --bundles '${{ matrix.bundles }}' --config "$RUNNER_TEMP/ci-updater/updater-build.json" + - name: Collect and verify every test bundle + run: python scripts/release.py collect --bundle 'src-tauri/target/${{ matrix.target }}/release/bundle' --directory ci-packages --platform '${{ matrix.platform }}' --version "$RELEASE_VERSION" + - uses: actions/upload-artifact@v4 with: - name: shacraft-launcher-windows-x64 + name: CI-NOT-FOR-RELEASE-${{ matrix.platform }} if-no-files-found: error - path: | - src-tauri/target/release/bundle/nsis/*.exe - src-tauri/target/release/bundle/msi/*.msi - - name: Upload Linux packages - if: runner.os == 'Linux' - uses: actions/upload-artifact@v4 - with: - name: shacraft-launcher-linux-x64 - if-no-files-found: error - path: | - src-tauri/target/release/bundle/appimage/*.AppImage - src-tauri/target/release/bundle/deb/*.deb - - name: Upload macOS package - if: runner.os == 'macOS' - uses: actions/upload-artifact@v4 - with: - name: shacraft-launcher-${{ matrix.name == 'macOS Apple Silicon' && 'macos-arm64' || 'macos-x64' }} - if-no-files-found: error - path: src-tauri/target/*/release/bundle/dmg/*.dmg + retention-days: 7 + path: ci-packages/* diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index ccc3db0..5feb871 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -21,6 +21,9 @@ jobs: with: node-version: 22 cache: npm + - uses: actions/setup-python@v5 + with: + python-version: '3.12' - uses: dtolnay/rust-toolchain@stable - name: Install Linux desktop dependencies run: | @@ -30,3 +33,5 @@ jobs: - run: npm test - run: npm run build - run: cargo test --locked --manifest-path src-tauri/Cargo.toml + - run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml + - run: python -m unittest discover -s scripts -p 'release_test.py' -v diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000..1637f66 --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,95 @@ +name: Publish verified release (operator only) + +on: + workflow_dispatch: + inputs: + version: + description: Version of the complete signed draft + required: true + type: string + tag: + description: Existing draft tag (vVERSION) + required: true + type: string + confirmation: + description: Type publish vVERSION to confirm public publication + required: true + type: string + +permissions: + contents: read + actions: read + +concurrency: + group: launcher-release + cancel-in-progress: false + +jobs: + preflight: + if: github.repository == 'emil28092005/shacraft-launcher' && github.ref == 'refs/heads/main' + runs-on: ubuntu-22.04 + outputs: + environment: ${{ steps.gate.outputs.environment }} + commit: ${{ steps.source.outputs.commit }} + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ inputs.tag }} + CONFIRMATION: ${{ inputs.confirmation }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Validate publication inputs + run: python -c "import os,sys; sys.path.insert(0,'scripts'); import release; release.version_tag(os.environ['RELEASE_VERSION'],os.environ['RELEASE_TAG']); release.require(os.environ['CONFIRMATION']=='publish '+os.environ['RELEASE_TAG'],'publication confirmation mismatch')" + - name: Require existing independent approval and protected-branch policy + id: gate + env: + GH_TOKEN: ${{ github.token }} + run: python scripts/release_github.py gate launcher-release-publish + - name: Resolve tag using trusted workflow Git commands + id: source + shell: bash + run: | + set -euo pipefail + RELEASE_COMMIT=$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}") + git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main + printf 'commit=%s\n' "$RELEASE_COMMIT" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v4 + with: + ref: ${{ steps.source.outputs.commit }} + fetch-depth: 0 + persist-credentials: false + - name: Check source versions after immutable source validation + run: python scripts/release.py preflight --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --check-git + + publish: + needs: preflight + environment: ${{ needs.preflight.outputs.environment }} + runs-on: ubuntu-22.04 + permissions: + contents: write + actions: read + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ inputs.tag }} + CONFIRMATION: ${{ inputs.confirmation }} + SHACRAFT_UPDATER_PUBLIC_KEY: ${{ vars.SHACRAFT_UPDATER_PUBLIC_KEY }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.preflight.outputs.commit }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@stable + - run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml + - name: Re-download, verify signatures/metadata/assets and explicitly publish + env: + GH_TOKEN: ${{ github.token }} + run: python scripts/release_github.py publish --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --confirmation "$CONFIRMATION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9eafe11 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,190 @@ +name: Prepare signed release draft + +on: + workflow_dispatch: + inputs: + version: + description: Stable version matching all source versions (e.g. 0.2.0) + required: true + type: string + tag: + description: Existing tag on protected main (e.g. v0.2.0) + required: true + type: string + notes: + description: Release notes (maximum 4096 UTF-8 bytes) + default: '' + type: string + +permissions: + contents: read + actions: read + +concurrency: + group: launcher-release + cancel-in-progress: false + +jobs: + preflight: + if: github.repository == 'emil28092005/shacraft-launcher' && github.ref == 'refs/heads/main' + runs-on: ubuntu-22.04 + outputs: + environment: ${{ steps.gate.outputs.environment }} + commit: ${{ steps.source.outputs.commit }} + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ inputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Validate inputs before resolving tag + run: python -c "import os,sys; sys.path.insert(0,'scripts'); import release; release.version_tag(os.environ['RELEASE_VERSION'],os.environ['RELEASE_TAG'])" + - name: Require an existing protected environment before any job references it + id: gate + env: + GH_TOKEN: ${{ github.token }} + run: python scripts/release_github.py gate launcher-release + - name: Resolve tag using trusted workflow Git commands + id: source + shell: bash + run: | + set -euo pipefail + RELEASE_COMMIT=$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}") + git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main + printf 'commit=%s\n' "$RELEASE_COMMIT" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v4 + with: + ref: ${{ steps.source.outputs.commit }} + fetch-depth: 0 + persist-credentials: false + - name: Check source versions, existing tag and main ancestry + run: python scripts/release.py preflight --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --check-git + + build: + needs: preflight + environment: ${{ needs.preflight.outputs.environment }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux-x86_64 + os: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + bundles: appimage,deb + - platform: windows-x86_64 + os: windows-2022 + target: x86_64-pc-windows-msvc + bundles: nsis,msi + - platform: darwin-aarch64 + os: macos-15 + target: aarch64-apple-darwin + bundles: dmg,app + - platform: darwin-x86_64 + os: macos-15-intel + target: x86_64-apple-darwin + bundles: dmg,app + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ inputs.tag }} + SHACRAFT_UPDATER_PUBLIC_KEY: ${{ vars.SHACRAFT_UPDATER_PUBLIC_KEY }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.preflight.outputs.commit }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - name: Install Linux desktop dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf + - run: npm ci + - run: npm test + - run: cargo test --locked --manifest-path src-tauri/Cargo.toml + - run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml + - run: python -m unittest discover -s scripts -p 'release_test.py' -v + - name: Fail closed on missing, wrong or unpinned signing credentials + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: python scripts/release.py preflight --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --check-git --signing --config "$RUNNER_TEMP/updater-build.json" + - name: Build all platform packages with updater signatures + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: npm run tauri:build -- --ci --target '${{ matrix.target }}' --bundles '${{ matrix.bundles }}' --config "$RUNNER_TEMP/updater-build.json" + - name: Collect packages, sign manual packages, verify all signatures + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: python scripts/release.py collect --bundle 'src-tauri/target/${{ matrix.target }}/release/bundle' --directory release-packages --platform '${{ matrix.platform }}' --version "$RELEASE_VERSION" + - uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.platform }} + if-no-files-found: error + retention-days: 7 + path: release-packages/* + + draft: + needs: [preflight, build] + environment: ${{ needs.preflight.outputs.environment }} + runs-on: ubuntu-22.04 + permissions: + contents: write + actions: read + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_TAG: ${{ inputs.tag }} + RELEASE_NOTES: ${{ inputs.notes }} + SHACRAFT_UPDATER_PUBLIC_KEY: ${{ vars.SHACRAFT_UPDATER_PUBLIC_KEY }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.preflight.outputs.commit }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@stable + - run: npm ci + - run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml + - uses: actions/download-artifact@v4 + with: + pattern: release-* + merge-multiple: true + path: release-packages + - name: Generate signed metadata, validate complete set, upload and re-verify DRAFT + env: + GH_TOKEN: ${{ github.token }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + python scripts/release.py preflight --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --check-git --signing + RELEASE_DATE=$(git show -s --format=%cI HEAD) + RELEASE_DATE=$(python -c "from datetime import datetime,timezone; import sys; print(datetime.fromisoformat(sys.argv[1]).astimezone(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))" "$RELEASE_DATE") + python scripts/release.py metadata --directory release-packages --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --date "$RELEASE_DATE" --notes "$RELEASE_NOTES" + python scripts/release_github.py draft --directory release-packages --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" diff --git a/.gitignore b/.gitignore index a295e3e..ca0713d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ src-tauri/gen/ *.p12 *.pfx *.sig + +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 68ff036..7f6112c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,8 +30,12 @@ payload are in `/root/shacraft` on the ShaCraft host; see embedded public key and `keyId` **before** parsing the payload. - `src-tauri/src/manifest.rs` then validates paths, SHA-256, sizes, HTTPS and allowed ShaCraft hosts. Do not weaken this whitelist. -- `src-tauri/src/profile.rs` downloads to a temporary sibling file, verifies - size + SHA-256, and atomically replaces only launcher-managed files. +- `profile.rs` / `inventory.rs` stage verified files, journal replacements and + retire only previously owned, unchanged managed files. Unknown files are not + silently adopted or deleted. Legacy mod backup requires explicit selection. +- Play/Repair use one `remote::VerifiedSnapshot` from verification through spawn. + `installation_lock.rs` protects the shared game tree across processes and + retains a PID + start-time lease while Minecraft is alive. - This ShaCraft manifest is the **only** source of truth for which Minecraft version / NeoForge version / Java major a profile needs (`Manifest.minecraft`) and for mod/config files. It never supplies a URL @@ -45,7 +49,25 @@ payload are in `/root/shacraft` on the ShaCraft host; see the nickname is fetched from the verified `aoc` account link; the legacy nickname in `settings.json` is ignored as an identity source. Server-side whitelist enforcement and LoginSystem remain the final access-control - boundary, including for old launcher versions. + boundary, including for old launcher versions. The explicit onboarding command + is the only unlinked launch path: a short-lived server grant goes to the game + child environment only, never IPC responses, argv or files. The signed pack + must contain ShaCraft Game Bridge; it binds the grant to the game session, + requires LoginSystem authentication and an explicit one-time proof command. + Existing links retain legacy provenance; status polling cannot create links. + +- Launcher self-update is a separate trust domain in `updater.rs` and + `updater/protocol.rs`: the fixed GitHub repository's `latest.json` bytes are + authenticated with the pinned Tauri/minisign public key before JSON parsing. + Each exact version/platform artifact also requires that signature, SHA-256 + and size. Never reuse the ShaCraft mod-manifest key or accept IPC URLs/keys. +- Linux self-update requires the original ordinary AppImage file and the frozen + Tauri `APPDIR` bound to the running `usr/bin/shacraft-launcher`. Extracted or + inherited AppImage context is manual-only; never replace a bare binary. +- `update_guard.rs` retains one launcher-instance OS lock, drains native writes + through `operations::Lifecycle`, and checks the existing detached-game lease. + `launcher-state/pending-update.json` survives installer handoff; only startup + of its exact target version acknowledges it. Never clear it on a timer. ## Layout @@ -59,7 +81,7 @@ payload are in `/root/shacraft` on the ShaCraft host; see - `lib.rs` — module/command registration only; `commands/` holds adapters for account/game/host/preferences/profiles. Unsigned sync/inspect IPC was removed; only verified remote manifests may drive profile mutations. - - `operations.rs` — process-local install/account permits owned by workers. + - `operations.rs` — lifecycle and install/account permits owned by workers. Launch must use the authenticated ShaCraft nickname; no settings fallback. - `storage.rs` — unique same-directory atomic writes, owner-only Unix files. - `trusted_http.rs` — HTTPS and exact-host redirect policy per game provider. @@ -69,7 +91,9 @@ payload are in `/root/shacraft` on the ShaCraft host; see build on this rather than each rolling their own. - `mojang.rs` — vanilla Minecraft trust boundary + the generic `inheritsFrom` version-JSON merge (shared with NeoForge's profile). - - `neoforge.rs` — runs NeoForge's official installer headlessly. + - `neoforge.rs` / `neoforge_repair.rs` — verified official installer, isolated + processor rebuild, checked embedded JSON and generated-output receipts. + Never hash legacy generated artifacts as an initial trusted baseline. - `runtime.rs` — Java 21 auto-provisioning via Eclipse Adoptium. - `msa.rs` — Microsoft/Xbox/Minecraft Services login; see `MSA_CLIENT_ID`'s doc comment before touching login — it is currently a @@ -85,8 +109,13 @@ payload are in `/root/shacraft` on the ShaCraft host; see - `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium trust domains used to install and run the game itself. - `.github/workflows/check.yml` — push/PR UI checks and Linux Rust tests. -- `.github/workflows/build.yml` — main-push/manual cross-platform builds with artifacts; - not a signed release or updater publication. +- `.github/workflows/build.yml` — four-platform CI packages with disposable test + signing keys, explicitly unusable as production releases. +- Release workflows and `scripts/release.py` implement protected draft → publish + gates; see `docs/updater-release.md`. Never publish assets piecemeal, reuse CI + test keys, or confuse updater signatures with OS signing/notarization. +- `src-tauri/updater-public-key.txt` is the public production trust root. + Private updater keys remain outside all repositories; never commit them. ## Verification diff --git a/PLAN.md b/PLAN.md index 27775b2..90a4953 100644 --- a/PLAN.md +++ b/PLAN.md @@ -17,16 +17,32 @@ approval и живой OAuth-тест. Текущий запуск использует ShaCraft identity. - [ ] Cold install / repair / update / game exit на чистых Windows/Linux/macOS. Unit tests и web preview не заменяют эти прогоны. -- [ ] Подписанные installer-релизы и подписанное автообновление лаунчера. +- [x] Native самообновление, русский UX, pinned updater key и защищённый release pipeline. +- [x] Локальные AppImage/deb собраны на Ubuntu 26.04 с CI test key; подписи и + форматы проверены. Настоящий AppImage прошёл изолированный startup/lock/marker + smoke. Это не проверка установки, самообновления или совместимости Ubuntu 22.04. +- [ ] Настроить защищённое GitHub environment/production secrets, опубликовать первый + updater-релиз и проверить реальную замену приложения на каждой ОС. Подпись ОС и + Apple notarization — отдельные настройки; CI test keys не предназначены игрокам. - [ ] Реальная отмена загрузок, журнал с редактированием токенов и retry UX. - [ ] Выбор каталога профиля и безопасный reset только managed-файлов. -- [ ] Keychain-хранилище refresh token; cross-process exclusion при необходимости. +- [ ] Keychain-хранилище сессии ShaCraft; OS-lock и lease игры уже реализованы. - [ ] Динамический каталог и новости; реальный Aeronautics онлайн уже загружается через фиксированный display-only API. Не имитировать данные. -## Связанные серверные риски +## Исправления по handoff (исходники, до выкладки) -Серверный план находится в `/root/shacraft/PLAN.md`. Важные следующие шаги: -одноразовое подтверждение ника внутри игры (NoGravity не связывает игрока -с веб-запросом), enforcement реферальных правил и очередь повторов whitelist. -Не менять этот протокол незаметно в клиентском рефакторинге. +- [x] Один signed snapshot и общий межпроцессный lock на Play/Repair. +- [x] Inventory, journal/recovery, retirement старых неизменённых managed-файлов. +- [x] Явный перенос выбранных legacy-модов в резервную копию. +- [x] NeoForge clean rebuild + provenance receipt; непустая порча обнаруживается. +- [x] Отдельный первый вход с серверным grant и одноразовым proof. +- [x] RAM retry сохраняет намерение; неверный JAVA_HOME не скрывает подходящий PATH. +- [x] Версии в интерфейсе берутся из проверенного manifest. +- [ ] Совместная выкладка backend/Game Bridge/подписанного payload и нового лаунчера. +- [ ] Изолированная игровая проверка LoginSystem + hold + proof, затем beta по ОС. + +Сопутствующий серверный код содержит read-only status, nonce proof с legacy +migration и durable grant/revoke outbox. Оплата и доставка whitelist — разные +состояния. Реферальные правила остаются отдельной задачей серверного PLAN; +это исправление не меняет условия покупки. Продакшен не изменён. diff --git a/README.md b/README.md index ac41dd6..f7da5ed 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,17 @@ React/TypeScript интерфейс, Rust — файлы, сеть и запус Реализованы подписанная синхронизация Aeronautics, проверка/восстановление модов и конфигурации, Java discovery/provisioning, bootstrap Minecraft и -NeoForge, настройки памяти и ника, обработка установки/запуска/выхода. +NeoForge, настройки памяти, обработка установки/запуска/выхода. Проверка +сборки теперь восстанавливает и игровые файлы; старые неизменённые managed-моды +убираются в резервную копию, неизвестные моды разбираются явно пользователем. Вход выполняется через аккаунт ShaCraft — тот же, что на сайте. Игровой ник берётся только из подтверждённой привязки Aeronautics, а не из редактируемых локальных настроек. Пароли не сохраняются; сессию можно отозвать. +Для первого входа без привязки добавлено отдельное действие в настройках +аккаунта: установка → серверное разрешение → LoginSystem → одноразовая команда +подтверждения. Оно требует совместной выкладки backend, Game Bridge и +подписанного модпака; наличие исходников не означает публикацию этого сценария. Microsoft OAuth-модуль сохранён отдельно, но не используется текущим сценарием запуска; для его активации потребуются client ID и API approval. diff --git a/docs/game-trust-boundary.md b/docs/game-trust-boundary.md index 2fe8882..e62eb96 100644 --- a/docs/game-trust-boundary.md +++ b/docs/game-trust-boundary.md @@ -90,3 +90,25 @@ URL — cannot redirect a download to an attacker-controlled host in any of these domains. When adding a new game-related download, verify its host is one of the ones above (or add a new hardcoded constant following the same pattern) rather than accepting a URL from anywhere else. + + +## Generated NeoForge artifacts and Java selection + +NeoForge 21.1.248's actual installer does not publish processor-output hashes. +The launcher therefore validates the installer SHA-256, extracts its expected +version JSON and a scoped processor recipe, verifies the vanilla input against +Mojang, and runs that installer in a fresh staging tree. Generated JARs must +be readable ZIPs with valid entries. A receipt commits the generated hashes, +installer digest and recipe identity only after successful verified promotion. +This is provenance from a trusted computation, not independent publisher +checksums for generated files. Later corruption invalidates the receipt and +triggers clean reconstruction; a nonempty old file is never enough. Interrupted +promotion has no valid receipt and is repaired on the next preparation. +Only recipe-scoped generated artifacts are promoted; user profile files are +outside this tree. Staging can remain after an abrupt kill and is never trusted +as an installation. The receipt currently lives under the shared game cache. + +Java discovery evaluates JAVA_HOME and PATH candidates independently for the +exact required major. A wrong-major JAVA_HOME no longer hides a valid PATH +installation. Only if no matching candidate exists is a managed runtime +provisioned. The UI reads required versions from verified profile metadata. diff --git a/docs/launcher-architecture.md b/docs/launcher-architecture.md index 2c56b92..087c7ca 100644 --- a/docs/launcher-architecture.md +++ b/docs/launcher-architecture.md @@ -6,26 +6,33 @@ The launcher persists local settings, synchronises Aeronautics mod/config files from the signed ShaCraft v2 manifest, installs the exact Minecraft + NeoForge version the manifest specifies, and launches the game. A player signs in with the same local ShaCraft account used on the website. The game -identity is derived only from that account's verified Aeronautics nickname; +identity for ordinary Play is derived only from that account's verified Aeronautics nickname; the legacy editable nickname setting is not trusted at launch. The interface also shows a live Aeronautics player count from the fixed, read-only `https://shacraft.ru/api/online/aoc` endpoint. It is display-only: the result never controls files, versions, URLs, or the launch command. -Not yet implemented: a user-selectable profile directory, a "reset managed -files only" recovery action, and signed cross-platform release builds of the -launcher itself. Do not represent these as completed in UI or release notes. +The launcher also checks signed GitHub releases for its own updates. AppImage, +Windows x64 installers and native Intel/Apple Silicon macOS app bundles use +explicit install-and-restart; Debian packages use manual package management. +Production publication and OS signing/notarization require operator setup; +see `updater-release.md`. Version 0.1.1 has no updater and must be upgraded +manually once. User-selectable profile directories and managed-only reset +remain unimplemented. ## Data flow -Two independent pipelines feed one launch: +One verified ShaCraft snapshot is fetched inside the installation lock for +each Play/Repair operation. Every stage, the displayed completion metadata and +the spawn use that snapshot; a newly published manifest is used next time. +Two independent trust pipelines feed one launch: ```text ShaCraft manifest (mods/config + which MC/loader/Java version to use) signed-manifest endpoint -> Ed25519 verification (remote.rs) -> manifest schema + URL/path validation (manifest.rs) - -> temporary download, SHA-256 verification, atomic replacement (profile.rs) + -> inventory reconciliation, verified staging, durable apply journal (profile.rs) Game itself (never controlled by the manifest above) Mojang version manifest -> SHA-1-verified version JSON (mojang.rs) @@ -68,7 +75,7 @@ be the system `.minecraft` directory. 1. User-selectable profile directory and structured launcher logs. 2. "Reset managed files only" recovery action that doesn't touch player worlds/screenshots/resourcepacks. -3. Signed, cross-platform release builds of the launcher itself. +3. Production release credentials/protected environments and OS beta validation. 4. Cancellation, structured logs and a full cold-install/recovery beta on every target OS. Install progress reports bytes or installer work counts depending on the stage; these units are not interchangeable. @@ -88,7 +95,10 @@ Browser preview cannot install/launch and does not simulate download progress. Rust `lib.rs` registers commands from `commands/`. Installation and account permits in `operations.rs` stay owned by blocking workers until completion. ShaCraft sessions have a separate gate from the retained Microsoft module. -These are process-local guards, not cross-process locks or cancellation. +An additional OS file lock in `installation_lock.rs` covers all profiles and +the shared game installation across processes. It remains held by the child +watcher; a durable PID/start-time lease also protects a game that outlives its +launcher. This is exclusion, not cancellation. `storage.rs` provides unique temporary files and atomic replacement; Unix session files are created owner-only. Windows keeps a recoverable replacement fallback if the OS refuses direct replacement. `trusted_http.rs` constrains provider @@ -101,7 +111,150 @@ Hostile same-user TOCTOU is outside this protection; it is not an OS sandbox. `npm test` covers asynchronous helpers and state transitions; `npm run build` runs strict TypeScript before Vite. `cargo test --locked` covers native policy and storage. Push/PR CI repeats checks on Linux. -The package workflow runs on main pushes or manually and builds Windows -x64, Linux x64 and both macOS architectures with named artifacts. -Packages are not yet signed release artifacts. Native cold-install and -launch tests are required before calling a platform release-ready. +The package workflow builds and checks Windows x64, Linux x64 and both macOS +architectures with disposable test signing keys. These artifacts cannot be +published as production updater releases. Separate protected workflows assemble +a complete signed release as a draft, re-verify its assets and only then publish. +Native cold-install, real self-update and launch tests are required before +calling a platform release-ready. CI package builds do not prove those flows. + + +## Reconciliation and recovery + +`profiles/..shacraft-state/` is outside the payload root. It stores +`inventory.json`, `pending.json`, transaction staging/backups/receipts and +explicit legacy-mod backups. The inventory records only files the launcher +actually wrote, with size/hash/policy and signed snapshot digest. An existing +file that already matches is usable, but is not silently claimed as owned. +Seed files are never owned for later removal. + +All required downloads are staged and verified before apply. A write-ahead +journal records old/new states. Interrupted apply resumes by checking hashes; +readiness stays false while a journal, stale owned files or conflicts remain. +Old owned files are moved to a backup only when their current contents still +match the recorded version. Changed files remain conflicts; unrelated files +and player worlds, screenshots, options and resourcepacks are preserved. +A manifest is not authority to overwrite an unknown colliding local file. + +“Разобрать моды” lists legacy/changed JAR candidates without selecting any. +The user explicitly chooses paths; native code rechecks each selected hash +and safe path before moving it to a recoverable backup with a receipt. Unknown +extra mods are informational, not automatically removed or adopted. Readiness +means the declared pack files were checked; it does not certify arbitrary +additional user mods. Afterwards rerun Repair or Play. + +`game/cache/neoforge-receipts/.json` records provenance for generated +NeoForge outputs. The verified installer recipe and its embedded version JSON +scope which files can be rebuilt. Missing/invalid receipts, corrupt JSON or a +mismatched generated JAR cause a clean isolated rebuild. No local legacy hash +is accepted as the first baseline. See `game-trust-boundary.md`. + +## First entry and account proof v1 + +The account settings offer “Установить и войти для подтверждения” separately +from ordinary Play. The launcher prepares the exact signed pack first, then +requests and validates an authenticated grant at the fixed ShaCraft origin: +`POST /api/launcher/onboarding/start` and `/validate`. It requires an approved +managed `mods/shacraft-game-bridge-*.jar` entry in that signed snapshot. Until +that payload and server bridge are deployed, this action fails with an explicit +message; ordinary linked Play keeps its existing account gate. + +The grant binds account, `aoc`, exact nickname/offline UUID, challenge and TTL +(10 minutes). Only the game child gets `SHACRAFT_ONBOARDING_TOKEN` in its +environment; normal launches remove inherited tokens. The frontend receives +only the explicit proof challenge. The client bridge sends the token once to +the fixed Aeronautics socket. The server bridge holds that connection until +LoginSystem succeeds and the player enters `/shacraft link `. +The bridge uses a server-only bearer secret for fixed HTTPS callbacks. A grant +is not a whitelist grant, password replacement or completed web link. + +New nicknames keep the established operator-whitelist → first `/register` +policy; registered names must use their existing `/login` password. Prism can +still perform the explicit proof command without a launcher grant. Existing +account links stay valid with legacy provenance and optional re-verification; +no bulk revocation occurs. GET link status is read-only. Backend implementation, +migrations, game adapter and rollout procedure are in the companion server +repository, `docs/account-proof-v1.md` and `docs/access-delivery.md`. + +## Interrupted spawn recovery + +`installation-state/writer.lock` is an OS lock file, not a stale-lock sentinel; +never delete it while any launcher/game is running. `game-lease.json` records +`Starting` before spawn and `Running` with PID/start time before releasing the +worker. A known exited child is cleared automatically. A crash in the narrow +spawn/record interval or malformed lease fails closed because the child cannot +be proven absent. + +For that explicit error only: close all ShaCraft Launcher and Minecraft +processes (or reboot), verify none remain, and rename `game-lease.json` to a +backup outside `installation-state`. Then open one launcher and run Repair. +Do not remove game/profile trees or an active lock file as a recovery shortcut. +The application data root is shown in settings; it is not system `.minecraft`. + +## Local live preparation check + +`cargo test --locked --manifest-path src-tauri/Cargo.toml +commands::game::tests::live_cold_install_and_corruption_repair -- --ignored --nocapture` +uses a fresh temporary application directory, real signed/provider downloads +and the official installer. It corrupts generated NeoForge JSON/JAR, repairs +both and repeats a healthy check. It never logs into a game server. It requires +network access and sufficient disk space; the printed directory is retained +for diagnosis. This does not replace Tauri IPC, graphical gameplay or the +client/server proof matrix on each supported OS. + + +## Launcher self-update boundary (0.2.0) + +`updater/protocol.rs` pins `github.com/emil28092005/shacraft-launcher/releases`. +The exact raw `latest.json` response is verified using its detached minisign +signature and the committed updater public key before versions, URLs or notes +are parsed. This key is independent of ShaCraft's Ed25519 mod manifest. Signed +metadata binds a stable version and tag to an exact set of four platform and +four manual package descriptors; every descriptor has an exact repository/tag/ +filename, size, SHA-256 and Tauri signature. HTTPS redirects are restricted to +that repository and GitHub's release asset CDN. Stable downgrades, unknown +platforms, missing signatures and incomplete metadata fail closed. + +Only native commands check, download, install and open the fixed releases page. +The webview supplies no URLs, public keys, executable arguments, release version +or arbitrary file path. No generic updater plugin permission is granted to it. +The packaged native architecture selects the artifact: Windows preserves MSI +versus NSIS, macOS preserves Intel versus Apple Silicon, Linux only replaces an +AppImage. The original non-symlink AppImage must have the expected native header, +and the frozen Tauri `APPDIR` must contain the actual running executable at +`usr/bin/shacraft-launcher`. Extracted binaries and inherited context from another +AppImage require manual installation. A Debian installation requires the user's +package manager. + +Checks run once per application UI lifecycle and on explicit request. They do +not install automatically. The settings drawer shows installed/available +versions, plain-text notes, progress, actionable errors and retry. The explicit +install button includes restart. UI session recovery codes, unsaved/failed +settings and account/game operations inhibit that action; native permits are +the final authority for concurrent writes. Preferences, sessions and game data +live outside the executable and are not migrated or erased by the updater. + +`launcher-state/instance.lock` is a process-lifetime OS lock, preventing an idle +second cooperating launcher from retaining old code during replacement. All +native account/settings/game writes hold shared lifecycle permits. Replacement +holds the exclusive permit and `installation-state/writer.lock`, which also +checks a game process's durable PID/start-time lease. A normal window close is +inhibited during download/install. Worker permits survive a dropped IPC future. +Before invoking the platform installer, `pending-update.json` records current +and target versions. The Windows plugin hands off and exits; the marker remains. +Only startup of the exact target version clears it. Old versions and indeterminate +installer failures block native mutations and direct the user to manual recovery. +A corrupt marker opens recovery diagnostics and latches the mutation/launch ban +until application restart, even if that file is removed while the UI is open. +No guessed installer timeout +releases the gate. This cannot retroactively make old 0.1.1 binaries cooperate +with these locks. + +First metadata/signature and artifact reads are bounded. Tauri updater 2.11.0 +requires its own second check to construct a private installer context; that +check uses the fixed version endpoint, HTTPS host policy and a 30-second timeout, +and its parsed metadata must equal the previously authenticated document. +The plugin's secondary response has no byte-limit API, leaving a memory-use +risk if that trusted release endpoint serves an unexpectedly large response. +Immediately before install, native code re-verifies artifact size/hash/signature; +`Update::install` alone does not perform signature verification. diff --git a/docs/updater-release.md b/docs/updater-release.md new file mode 100644 index 0000000..0df67b2 --- /dev/null +++ b/docs/updater-release.md @@ -0,0 +1,307 @@ +# Launcher updater releases + +The updater has its own signing key and fixed repository: +`emil28092005/shacraft-launcher`. It is independent of the ShaCraft modpack +manifest key. The committed public key is `src-tauri/updater-public-key.txt`; +the native client and release validation pin that key. An unset/placeholder key +cannot produce a release. + +## Packages and signed metadata + +The four build targets each produce two distributable files, with canonical names: + +- Windows x64: `shacraft-launcher_VERSION_windows-x86_64-setup.exe` (NSIS) + and `shacraft-launcher_VERSION_windows-x86_64.msi`. +- Linux x64: `shacraft-launcher_VERSION_linux-x86_64.AppImage` and + `shacraft-launcher_VERSION_linux-x86_64.deb`. +- macOS arm64: `shacraft-launcher_VERSION_darwin-aarch64.dmg` and + `shacraft-launcher_VERSION_darwin-aarch64.app.tar.gz`. +- macOS x64: `shacraft-launcher_VERSION_darwin-x86_64.dmg` and + `shacraft-launcher_VERSION_darwin-x86_64.app.tar.gz`. + +Every package has a detached `.sig` from the Tauri signer. AppImage, NSIS, MSI +and app tar signatures must already exist after `tauri build`; absence is an +error. The collection step signs deb and DMG explicitly. Renaming a package does +not alter its bytes or signature. The macOS app archive contains the `.app` +bundle; the DMG remains the manual installation package. These artifact formats +follow [Tauri's updater documentation](https://v2.tauri.app/plugin/updater/). + +`latest.json` contains exactly `schemaVersion` (integer 1), `version`, `tag`, +`notes`, `pub_date`, `platforms` and `manualPackages`. `version` is stable +`MAJOR.MINOR.PATCH`; `tag` is exactly `vVERSION`. Windows MSI limits apply: +major/minor at most 255 and patch at most 65535. `pub_date` uses UTC +`YYYY-MM-DDTHH:MM:SSZ`, derived from the tagged source commit. + +`platforms` has exactly `windows-x86_64`, `linux-x86_64`, `darwin-aarch64` and +`darwin-x86_64`. Their primary packages are NSIS, AppImage and the two app archives. +`manualPackages` has exactly `windows-x86_64-msi`, `linux-x86_64-deb`, +`darwin-aarch64-dmg` and `darwin-x86_64-dmg`. Despite the section name, the native +client also selects the MSI descriptor when updating an MSI installation. + +Each descriptor has exactly `url`, `signature`, `sha256` and `size`. URLs are +bound to the exact version/tag and canonical filename under +`https://github.com/emil28092005/shacraft-launcher/releases/download/vVERSION/`. +SHA-256 is lowercase hexadecimal; size is a positive integer at most 1 GiB. +Metadata is at most 32 KiB, notes at most 4096 UTF-8 bytes and signatures at most +2048 characters. + +The release script writes deterministic UTF-8 JSON with sorted keys, two-space +indentation and a trailing newline. It signs those exact bytes into +`latest.json.sig` using the same updater key. The client verifies this signature +before parsing: an artifact signature alone cannot bind a version or download +URL. The signed metadata binds the complete platform set, versions, filenames, +hashes, sizes and package signatures together. + +`scripts/release-verifier` uses `minisign-verify` 0.2.5 and the same verification +sequence as the [Tauri updater 2.11 implementation](https://github.com/tauri-apps/plugins-workspace/blob/v2/plugins/updater/src/updater.rs): +base64-decode the Tauri public key/signature containers, decode the minisign +objects, then verify the exact file bytes including the trusted comment. Missing, +nonempty-but-invalid and mismatched-key signatures all fail. The utility does +not execute installers or replace applications. + +## CI and protected draft creation + +`check.yml` runs the usual UI/Rust checks plus release tests. `build.yml` builds +all four targets with fresh disposable test keys. It has read-only repository +permissions and receives no production signing secret. Transient build config +and `SHACRAFT_UPDATER_TEST_BUILD=1` select the test key; no committed pin changes. +Artifacts are labelled `CI-NOT-FOR-RELEASE-*`, include `CI_NOT_FOR_RELEASE.txt`, +and cannot pass release validation. Test keys are never uploaded. + +Before a release, an operator must configure two existing GitHub environments: +`launcher-release` and `launcher-release-publish`. Both require a reviewer other +than the dispatcher (`prevent_self_review=true`) and permit protected branches +only. `main` must be protected. This is a two-person operation: the dispatcher +cannot approve their own deployment. The workflow gate checks these settings +through GitHub's [environment API](https://docs.github.com/en/rest/deployments/environments) +before exposing an environment name to later jobs. Missing environments or +insufficient API permissions stop the workflow; it never creates an unprotected +replacement or bypasses the check. + +Set `SHACRAFT_UPDATER_PUBLIC_KEY` as an environment/repository variable, equal to +the complete committed base64 public key. `launcher-release` alone needs the +`TAURI_SIGNING_PRIVATE_KEY` secret (official Tauri encoded key contents) and, +for an encrypted key, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. The password may be +omitted/empty for an unencrypted key; signing is noninteractive. The publish +environment needs only the public variable, never the private key. + +The private key must live outside Git, build artifacts and web roots. Keep the +key directory owner-only (0700), its key file owner-only (0600), and retain a +separate secure backup outside Git. Do not print the key, upload it as an +artifact or keep a purported encryption password beside it. Losing the key +breaks updates for installed clients; changing the public key is a coordinated +migration, not routine key regeneration. + +To prepare a release: + +1. Merge the reviewed source/version change. Package JSON, npm lock, Cargo + package and Tauri config versions must match. Create the exact `vVERSION` + tag on a commit belonging to protected `main`; the workflow never creates or + moves a tag. +2. Manually run **Prepare signed release draft** from `main` with that version, + tag and release notes. Pass the independent environment approval. Every + matrix runner checks the tag/versions and signs a challenge to prove the + supplied private key matches the committed public key before packaging. +3. All four jobs must finish. Only then does the final job assemble all eight + packages, all eight signatures and signed metadata. It verifies this complete + 18-file set before creating a GitHub draft, uploads it, downloads it again + and re-verifies the draft. A partial upload remains a draft and cannot pass + publication validation. An existing draft is never silently overwritten. + +Before any candidate source runs, trusted workflow Git commands prove that the +tag commit belongs to protected main. Later jobs check out the validated immutable +SHA, not the movable tag. Checkout credentials are removed, and the publication +token exists only in the final explicit publishing step. + +The tag/release workflow is manual; pushing a tag does not publish an update. +The scripts do not configure GitHub environments, keys or secrets. Repository +access alone is not authorization to run the publication workflow. + +## Explicit final publication + +After reviewing the draft and completing platform acceptance, an operator runs +**Publish verified release (operator only)** from `main`, supplies version/tag +and the exact confirmation `publish vVERSION`, then obtains the separate +publish-environment approval. This job has no signing key. It checks the source +versions/tag, current committed public pin, environment policy and monotonic +stable release version; downloads every draft asset and checks all signatures, +hashes, sizes, names and signed metadata again. It also checks asset IDs/sizes/ +digests did not change during validation. Only this step changes `draft` to false +and marks the release latest. Failures leave the draft unpublished. + +GitHub `latest/download/latest.json` and its detached signature can temporarily +refer to different releases during CDN propagation. The client must reject that +mismatch and retry; it must never accept unsigned metadata as a fallback. + +## Acceptance and bootstrap + +Updater signatures authenticate update bytes. They are **not** Windows +Authenticode signatures, Apple Developer ID signatures or Apple notarization. +This pipeline does not provision those certificates or claim that SmartScreen +or Gatekeeper will trust a manually downloaded package. Any OS signing/notarization +step must finish before updater signing and metadata hashing. Never modify a +package after its updater signature is created. + +Version 0.1.1 has no updater: users must install the first updater-enabled release +manually. Keeping application identifiers and installer families stable is +necessary, but unit tests do not establish upgrade compatibility. Before final +publication, validate actual old-to-new NSIS and MSI installations separately, +including per-user/elevated installation and preservation of account/settings/ +Minecraft data. The native updater preserves the MSI/NSIS family. + +Validate both macOS architectures on real supported systems, including writable +and protected application locations and process restart. Linux automatic +replacement is for an AppImage running from a writable AppImage location; a deb +installation shows availability and opens the fixed official releases page; +installation then uses the normal OS package installer. Non-AppImage or unwritable installations must not be treated +as successfully self-updated. Verify interrupted download, wrong signature, +current/no-update, relaunch and concurrent game/install behavior on each platform. + +Local release tests use temporary synthetic payloads and newly generated +throwaway keys. They cover real signature verification, bit flips, wrong keys, +metadata substitution, exact artifact sets, duplicate fields, version/tag +bindings, CI promotion rejection and missing environment protection. They do +not prove any Windows/macOS installer ran, a live GitHub release was published, +or an end user's application updated successfully. + +## Installed package migration and recovery + +The settings drawer reports the installed native package family. Automatic +updates preserve it: + +- Windows NSIS x64 → NSIS x64, retaining the existing per-user scope and saved + installation location. The signed package must contain an EXE, not MSI bytes. +- Windows MSI x64 → MSI x64, retaining the per-machine installer family. The + pinned UpgradeCode `2058b1df-56a1-51ef-bd48-d296479cd59a` is the exact value + Tauri CLI 2.11.4 derived for the existing 0.1.1 product name; ProductCode can + change for a major upgrade. Administrator permission may be required. +- MSI ↔ NSIS, simultaneous installations of both, renamed products, changed + scopes and manually moved Windows installations have no automatic migration + promise. Close the old application and use an explicit manual installer path; + verify installed-app registrations and account/settings preservation in beta. +- macOS Intel → Intel app archive, Apple Silicon → Apple Silicon app archive. + DMG is the bootstrap/manual distribution. Move the app out of a mounted DMG + into an appropriate Applications directory before use. A protected destination + may require OS permission or a manual replacement; an installation error never + counts as a completed update. +- Linux x64 AppImage → x64 AppImage at its current writable location. The original + ordinary, non-symlink AppImage must exist, and the frozen Tauri `APPDIR` must + match the running `usr/bin/shacraft-launcher`. Extracted AppDirs and an inherited + environment from another AppImage use manual installation. Debian + packages, RPM, bare development binaries and unsupported architectures never + enter the self-replacement path. Install a new deb through the system package + manager; the launcher does not run privileged package-manager commands. + +These paths follow the locked [Tauri MSI implementation](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle/windows/msi/mod.rs), +[NSIS installer template](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle/windows/templates/installer.nsi) +and [updater implementation](https://github.com/tauri-apps/plugins-workspace/blob/updater-v2.11.0/plugins/updater/src/updater.rs). +Source inspection and CI packaging do not replace real installation acceptance. + +The updater writes `launcher-state/pending-update.json` immediately before the +platform installer. Once handed off, cancellation, power loss and installer +errors can be ambiguous. Windows does not expose an installer PID/completion +result through the plugin, so the launcher never guesses that installation has +finished after a timeout. The exact target version acknowledges the marker on +startup. Until then, recovery mode blocks native game, account, settings and +update mutations. Corrupt marker bytes also open this diagnostic mode, never +normal operation. Its restriction stays latched even if the file is removed +while that process is open. Unix marker creation/acknowledgment syncs the parent +directory as well as file contents. + +For an interrupted update: + +1. Close Minecraft, all launcher instances and any installer; reboot if their + termination cannot be established. Do not delete active OS lock files. +2. Install the marker's expected target version manually from the fixed official + releases page, preserving the package family above. Replace only the app or + package, keeping the application data directory. Starting that target version + acknowledges a valid marker automatically. +3. If the marker is corrupt, or the target cannot be used, first manually restore + a complete official package of the intended supported version. With every + launcher/game/installer closed, rename only `launcher-state/pending-update.json` + to a backup outside `launcher-state`, then launch again. This is an operator + recovery after a complete package repair, not a shortcut around an active + installer. Keep the backup for diagnosis. Never remove settings, sessions, + profiles, game files, `instance.lock`, `writer.lock` or a live game lease. + +Default application data roots (or the configured XDG data home on Linux): + +- Windows: `%APPDATA%\ru.shacraft.launcher`. +- macOS: `~/Library/Application Support/ru.shacraft.launcher`. +- Linux: `~/.local/share/ru.shacraft.launcher`. + +Network/hash/signature failures before installer entry leave the app intact and +permit an explicit fresh check/retry. They do not create an install handoff or +require marker recovery. A separate “game still running” error must be resolved +by closing the game, not removing its lease. + + +## Package architecture checks + +Before collection/signing, scripts inspect package bytes without running an +installer. AppImage must have an ELF64 little-endian AMD64 header and the Type 2 +`AI\x02` marker. The deb ar/control archive must declare `Architecture: amd64` +and the release version. Each macOS app archive must have exactly one +`Info.plist`, the same release version, and a regular main executable with a +thin Mach-O64 CPU type matching its matrix target. DMG checks validate the UDIF +container trailer; they do not mount or inspect the DMG filesystem. + +On the Windows build runner, the built launcher must be AMD64 PE32+. NSIS uses +an x86 installer stub even for an x64 application: the checker accepts that +wrapper, uses the runner's 7-Zip to list/extract only the named launcher to +stdout, then requires an x64 payload identical to the built main executable after the +exact Tauri bundle-type stamp described below. +It never executes NSIS. Missing/unsupported 7-Zip inspection fails the build; +there is no silent architecture-check fallback. MSI is checked for a compound +file header and read using WindowsInstaller COM with `MSIDBOPEN_READONLY`: +Template Summary must say `x64` and ProductVersion must match. The MSI check also +checks the built main executable, but does not extract MSI's embedded cabinet or +prove that cabinet's payload matches the build. Real installer acceptance is +still required. These distinctions follow the [PE format](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format) +and [64-bit MSI package requirements](https://learn.microsoft.com/en-us/windows/win32/msi/using-64-bit-windows-installer-packages). + +The locked [Tauri CLI 2.11.4 bundler](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle.rs) +replaces the first complete `__TAURI_BUNDLE_TYPE_VAR_UNK` token with +`__TAURI_BUNDLE_TYPE_VAR_NSS` for NSIS (`MSI` for MSI), packages that binary, then +restores the original unpatched/unsigned executable on disk after each bundle. +The NSIS comparator constructs precisely that one replacement in a copy of the +built bytes and compares the entire extracted payload. It does not mask any PE +section, checksum, certificate table, padding or other bytes. A missing marker, +wrong family stamp or any unrelated byte change fails. Both original and +extracted executables must still be AMD64 PE32+. + +Authenticode is currently unconfigured. Adding it can also change the PE checksum +and append a certificate table; this comparison intentionally fails until a +separate verified signed-baseline procedure is implemented. Do not broaden the +comparison to ignore all certificate/checksum differences merely to pass CI. + +Portable format checks repeat when validating signed release assets. Full NSIS +payload/MSI COM checks run only during collection on the Windows runner; the +Linux draft/publish verifier rechecks their container headers and signatures. +Header/metadata inspection is not a runtime, architecture-emulation or installer +migration test. Synthetic header fixtures exercise these checks; they are never +installed or executed. + + +## Local package verification, 2026-09-09 + +Application source `696c6b0` was built on Ubuntu 26.04 x86_64 using a disposable +CI updater key and the visible test-build flag. Actual AppImage and deb packages +passed the same collection checks used by the workflow: expected container, +architecture/version where represented, and cryptographic updater signatures. +The manual-package collector explicitly signs deb/DMG even when a CLI version +also emits their signatures. No production package or release was signed. + +The actual AppImage was started with `--appimage-extract-and-run`, isolated XDG +roots and a pending update targeting a different version. The native executable +ran inside its own `APPDIR/usr/bin/shacraft-launcher`, referenced the expected +`APPIMAGE`, held the instance OS lock and preserved the pending marker during +startup and after termination. This checks native startup and the runtime binding; +it does not establish visual/IPC behavior, installation, self-replacement or a +successful updater restart. The deb was inspected, not installed. + +This local Ubuntu 26.04 build is not a compatibility result for Ubuntu 22.04 or +other distributions. The four-platform GitHub matrix, real Windows/macOS +installations, old-version migration and end-to-end update acceptance remain +pending. Production signing credentials and release environments are not +configured on GitHub, and no release has been published. diff --git a/package-lock.json b/package-lock.json index 79b527c..c6d9086 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "shacraft-launcher-ui", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shacraft-launcher-ui", - "version": "0.1.1", + "version": "0.2.0", "dependencies": { "@tauri-apps/api": "2.11.1", "lucide-react": "1.41.0", diff --git a/package.json b/package.json index d08e790..22c4454 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,13 @@ "name": "shacraft-launcher-ui", "license": "MIT", "private": true, - "version": "0.1.1", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", "build": "npm run typecheck && vite build", "typecheck": "tsc --noEmit", - "test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts src/state/account.test.ts src/components/account.test.tsx", + "test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts src/state/account.test.ts src/components/account.test.tsx src/state/updater.test.ts src/components/updater.test.tsx", "preview": "vite preview", "tauri": "tauri", "tauri:dev": "tauri dev", diff --git a/scripts/release-verifier/.gitignore b/scripts/release-verifier/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/scripts/release-verifier/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/scripts/release-verifier/Cargo.lock b/scripts/release-verifier/Cargo.lock new file mode 100644 index 0000000..39cb1a2 --- /dev/null +++ b/scripts/release-verifier/Cargo.lock @@ -0,0 +1,23 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + +[[package]] +name = "shacraft-release-verifier" +version = "0.1.0" +dependencies = [ + "base64", + "minisign-verify", +] diff --git a/scripts/release-verifier/Cargo.toml b/scripts/release-verifier/Cargo.toml new file mode 100644 index 0000000..0c85158 --- /dev/null +++ b/scripts/release-verifier/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "shacraft-release-verifier" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +base64 = "=0.22.1" +minisign-verify = "=0.2.5" diff --git a/scripts/release-verifier/src/main.rs b/scripts/release-verifier/src/main.rs new file mode 100644 index 0000000..cef8b5b --- /dev/null +++ b/scripts/release-verifier/src/main.rs @@ -0,0 +1,32 @@ +//! Uses precisely the Tauri updater 2.11 signature verification primitive. +//! Upstream: plugins/updater/src/updater.rs::verify_signature (MIT/Apache-2.0). +use base64::Engine; +use minisign_verify::{PublicKey, Signature}; +use std::{env, fs, process::ExitCode}; + +fn verify() -> Result<(), Box> { + let args: Vec<_> = env::args_os().skip(1).collect(); + if args.len() != 3 { + return Err("expected PUBLIC_KEY_FILE DATA_FILE SIGNATURE_FILE".into()); + } + let decode = |path: &std::ffi::OsStr| -> Result> { + let encoded = fs::read_to_string(path)?; + let bytes = base64::engine::general_purpose::STANDARD.decode(encoded.trim())?; + Ok(String::from_utf8(bytes)?) + }; + let public_key = PublicKey::decode(&decode(&args[0])?)?; + let signature = Signature::decode(&decode(&args[2])?)?; + public_key.verify(&fs::read(&args[1])?, &signature, true)?; + Ok(()) +} + +fn main() -> ExitCode { + match verify() { + Ok(()) => ExitCode::SUCCESS, + Err(_) => { + // Never echo key material or signer diagnostics into release logs. + eprintln!("Tauri updater signature verification failed"); + ExitCode::FAILURE + } + } +} diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..a4f13b3 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +"""Assemble/verify release assets. No network or publication in this module.""" + +import argparse +import base64 +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import tomllib +from datetime import datetime +from pathlib import Path + +import release_formats + +REPOSITORY = "emil28092005/shacraft-launcher" +ORIGIN = f"https://github.com/{REPOSITORY}/releases/download" +PLATFORMS = { + "windows-x86_64": ("nsis/*.exe", "-setup.exe"), + "linux-x86_64": ("appimage/*.AppImage", ".AppImage"), + "darwin-aarch64": ("macos/*.app.tar.gz", ".app.tar.gz"), + "darwin-x86_64": ("macos/*.app.tar.gz", ".app.tar.gz"), +} +MANUAL = { + "windows-x86_64-msi": ("windows-x86_64", "msi/*.msi", ".msi"), + "linux-x86_64-deb": ("linux-x86_64", "deb/*.deb", ".deb"), + "darwin-aarch64-dmg": ("darwin-aarch64", "dmg/*.dmg", ".dmg"), + "darwin-x86_64-dmg": ("darwin-x86_64", "dmg/*.dmg", ".dmg"), +} +FIELDS = {"url", "signature", "sha256", "size"} +MAX_SIZE = 1024**3 + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def version_tag(version, tag): + require( + re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version), + "release version must be stable MAJOR.MINOR.PATCH", + ) + major, minor, patch = map(int, version.split(".")) + require(major <= 255 and minor <= 255 and patch <= 65535, "version exceeds MSI limits") + require(tag == f"v{version}", "tag/version mismatch") + + +def filename(version, platform, suffix): + return f"shacraft-launcher_{version}_{platform}{suffix}" + + +def expected_names(version): + return {filename(version, key, value[1]) for key, value in PLATFORMS.items()} | { + filename(version, platform, suffix) for platform, _, suffix in MANUAL.values() + } + + +def canonical(data): + return (json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def unique_object(pairs): + result = {} + for key, value in pairs: + require(key not in result, "duplicate JSON field") + result[key] = value + return result + + +def read_json(path): + return json.loads(path.read_bytes(), object_pairs_hook=unique_object) + + +def public_key(): + value = os.environ.get("SHACRAFT_UPDATER_PUBLIC_KEY", "").strip() + require(bool(value), "SHACRAFT_UPDATER_PUBLIC_KEY is missing") + try: + decoded = base64.b64decode(value, validate=True).decode("utf-8") + lines = decoded.splitlines() + raw = base64.b64decode(lines[1], validate=True) + require( + lines[0].startswith("untrusted comment:") and len(raw) == 42 and raw[:2] == b"Ed", + "invalid Tauri public key", + ) + except (ValueError, IndexError, UnicodeError) as error: + raise ValueError("invalid Tauri public key") from error + return value + + +def verifier_path(root): + return os.environ.get( + "RELEASE_VERIFIER", + str( + root + / "scripts/release-verifier/target/release" + / ("shacraft-release-verifier.exe" if os.name == "nt" else "shacraft-release-verifier") + ), + ) + + +def verify_signature(root, data, signature): + with tempfile.TemporaryDirectory(prefix="shacraft-verify-") as temporary: + key = Path(temporary) / "public-key" + key.write_text(public_key(), encoding="utf-8") + result = subprocess.run( + [verifier_path(root), str(key), str(data), str(signature)], + capture_output=True, + timeout=120, + check=False, + ) + require(result.returncode == 0, f"invalid updater signature: {data.name}") + + +def signer(root, data): + private = os.environ.get("TAURI_SIGNING_PRIVATE_KEY", "") + require(bool(private.strip()), "TAURI_SIGNING_PRIVATE_KEY is missing") + environment = dict(os.environ) + # An omitted password means an unencrypted key. Never allow a CI prompt; + # encrypted keys without the correct password fail in the signer. + environment.setdefault("TAURI_SIGNING_PRIVATE_KEY_PASSWORD", "") + # Builds accept a key path; signer sign accepts the encoded key contents. + try: + is_key_path = len(private) < 4096 and "\n" not in private and Path(private).is_file() + except OSError: + is_key_path = False + if is_key_path: + environment["TAURI_SIGNING_PRIVATE_KEY"] = Path(private).read_text().strip() + environment.pop("TAURI_SIGNING_PRIVATE_KEY_PATH", None) + result = subprocess.run( + ["node", str(root / "node_modules/@tauri-apps/cli/tauri.js"), "signer", "sign", str(data)], + env=environment, + capture_output=True, + timeout=120, + check=False, + ) + require(result.returncode == 0, "Tauri signing failed (check protected signing credentials)") + + +def preflight(root, version, tag, check_git=False, signing=False): + version_tag(version, tag) + versions = [ + read_json(root / "package.json")["version"], + read_json(root / "package-lock.json")["version"], + read_json(root / "package-lock.json")["packages"][""]["version"], + read_json(root / "src-tauri/tauri.conf.json")["version"], + tomllib.loads((root / "src-tauri/Cargo.toml").read_text())["package"]["version"], + ] + require(all(value == version for value in versions), "source versions disagree with release") + if check_git: + + def git(*args): + return subprocess.check_output(["git", *args], cwd=root, text=True).strip() + + require( + git("rev-parse", "HEAD") == git("rev-parse", f"refs/tags/{tag}^{{commit}}"), + "checkout does not match existing release tag", + ) + subprocess.run( + ["git", "merge-base", "--is-ancestor", "HEAD", "origin/main"], cwd=root, check=True + ) + if signing: + require(os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") != "1", "CI test keys cannot release") + pinned = (root / "src-tauri/updater-public-key.txt").read_text().strip() + require(public_key() == pinned, "release public key differs from committed updater key") + with tempfile.TemporaryDirectory(prefix="shacraft-key-check-") as temporary: + challenge = Path(temporary) / "key-check" + challenge.write_bytes(f"ShaCraft release key check {tag}\n".encode()) + signer(root, challenge) + verify_signature(root, challenge, Path(str(challenge) + ".sig")) + + +def write_build_config(destination): + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes( + canonical( + { + "bundle": {"createUpdaterArtifacts": True}, + "plugins": {"updater": {"pubkey": public_key()}}, + } + ) + ) + + +def ci_key(root, directory): + require(not os.environ.get("TAURI_SIGNING_PRIVATE_KEY"), "CI must not receive production key") + directory.mkdir(parents=True, exist_ok=True) + key = directory / "DISPOSABLE-CI-ONLY.key" + result = subprocess.run( + [ + "node", + str(root / "node_modules/@tauri-apps/cli/tauri.js"), + "signer", + "generate", + "--ci", + "--password", + "", + "--write-keys", + str(key), + ], + capture_output=True, + timeout=120, + check=False, + ) + require(result.returncode == 0, "disposable CI key generation failed") + key.chmod(0o600) + values = { + "TAURI_SIGNING_PRIVATE_KEY": str(key), + "TAURI_SIGNING_PRIVATE_KEY_PASSWORD": "", + "SHACRAFT_UPDATER_PUBLIC_KEY": Path(str(key) + ".pub").read_text().strip(), + "SHACRAFT_UPDATER_TEST_BUILD": "1", + } + os.environ.update(values) + write_build_config(directory / "updater-build.json") + with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as stream: + for name, value in values.items(): + require("\n" not in value and "\r" not in value, "invalid CI environment value") + stream.write(f"{name}={value}\n") + + +def collect(root, bundle, destination, platform, version): + version_tag(version, f"v{version}") + destination.mkdir(parents=True, exist_ok=True) + require(not list(destination.iterdir()), "collection destination must be empty") + entries = [(PLATFORMS[platform][0], PLATFORMS[platform][1], True)] + entries += [ + (glob, suffix, suffix == ".msi") for key, glob, suffix in MANUAL.values() if key == platform + ] + for glob, suffix, required_signature in entries: + matches = list(bundle.glob(glob)) + require( + len(matches) == 1 and matches[0].is_file() and not matches[0].is_symlink(), + f"expected exactly one {platform} {glob}", + ) + source = matches[0] + main = bundle.parent / "shacraft-launcher.exe" if platform == "windows-x86_64" else None + release_formats.validate(source, platform, suffix, version, main) + target = destination / filename(version, platform, suffix) + shutil.copyfile(source, target) + source_signature = Path(str(source) + ".sig") + signature = Path(str(target) + ".sig") + if required_signature: + require( + source_signature.is_file() and not source_signature.is_symlink(), + f"missing generated updater signature: {source.name}", + ) + shutil.copyfile(source_signature, signature) + else: + signer(root, target) # Sign manual packages explicitly, independent of bundler output. + verify_signature(root, target, signature) + if os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") == "1": + (destination / "CI_NOT_FOR_RELEASE.txt").write_text( + "DISPOSABLE TEST KEY. These CI artifacts are not deployable releases.\n", + encoding="utf-8", + ) + + +def descriptor(root, directory, version, tag, platform, suffix): + name = filename(version, platform, suffix) + path, sig = directory / name, directory / (name + ".sig") + require( + path.is_file() and not path.is_symlink() and sig.is_file() and not sig.is_symlink(), + f"missing or unsafe release asset: {name}", + ) + size = path.stat().st_size + require(0 < size <= MAX_SIZE, "invalid package size") + verify_signature(root, path, sig) + release_formats.validate(path, platform, suffix, version) + with path.open("rb") as stream: + digest = hashlib.file_digest(stream, "sha256").hexdigest() + signature = sig.read_text(encoding="utf-8").strip() + require(0 < len(signature) <= 2048, "invalid signature length") + return {"url": f"{ORIGIN}/{tag}/{name}", "signature": signature, "sha256": digest, "size": size} + + +def metadata(root, directory, version, tag, notes, date): + version_tag(version, tag) + require(len(notes.encode()) <= 4096, "release notes too long") + require( + re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", date), "expected UTC publication date" + ) + datetime.fromisoformat(date.replace("Z", "+00:00")) + return { + "schemaVersion": 1, + "version": version, + "tag": tag, + "notes": notes, + "pub_date": date, + "platforms": { + key: descriptor(root, directory, version, tag, key, suffix) + for key, (_, suffix) in PLATFORMS.items() + }, + "manualPackages": { + key: descriptor(root, directory, version, tag, platform, suffix) + for key, (platform, _, suffix) in MANUAL.items() + }, + } + + +def verify_release(root, directory, version, tag): + version_tag(version, tag) + names = expected_names(version) + expected = names | {name + ".sig" for name in names} | {"latest.json", "latest.json.sig"} + require( + {path.name for path in directory.iterdir()} == expected, + "release asset set is incomplete or unexpected", + ) + path = directory / "latest.json" + require(path.stat().st_size <= 32768, "metadata exceeds limit") + require( + not path.is_symlink() and not (directory / "latest.json.sig").is_symlink(), + "unsafe metadata", + ) + verify_signature( + root, path, directory / "latest.json.sig" + ) # Verify exact bytes BEFORE parsing. + actual = read_json(path) + require( + isinstance(actual, dict) + and set(actual) + == {"schemaVersion", "version", "tag", "notes", "pub_date", "platforms", "manualPackages"}, + "unexpected metadata fields", + ) + require( + type(actual["schemaVersion"]) is int and actual["schemaVersion"] == 1, + "unsupported metadata schema", + ) + require(actual["version"] == version and actual["tag"] == tag, "signed version/tag mismatch") + require( + isinstance(actual["notes"], str) and isinstance(actual["pub_date"], str), + "invalid metadata text", + ) + for section, keys in (("platforms", PLATFORMS), ("manualPackages", MANUAL)): + require( + isinstance(actual[section], dict) and set(actual[section]) == set(keys), + "unexpected platform/package set", + ) + for item in actual[section].values(): + require(isinstance(item, dict) and set(item) == FIELDS, "unexpected descriptor fields") + require( + type(item["size"]) is int and 0 < item["size"] <= MAX_SIZE, + "invalid descriptor size", + ) + require( + all(isinstance(item[name], str) for name in ("url", "signature", "sha256")), + "invalid descriptor text", + ) + expected_metadata = metadata(root, directory, version, tag, actual["notes"], actual["pub_date"]) + require( + actual == expected_metadata, + "metadata does not match exact platforms, URLs, hashes, sizes or signatures", + ) + require(path.read_bytes() == canonical(expected_metadata), "metadata is not canonical") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + sub = parser.add_subparsers(dest="command", required=True) + check = sub.add_parser("preflight") + check.add_argument("--version", required=True) + check.add_argument("--tag", required=True) + check.add_argument("--check-git", action="store_true") + check.add_argument("--signing", action="store_true") + check.add_argument("--config", type=Path) + ci = sub.add_parser("ci-key") + ci.add_argument("--directory", required=True, type=Path) + gather = sub.add_parser("collect") + gather.add_argument("--bundle", required=True, type=Path) + gather.add_argument("--directory", required=True, type=Path) + gather.add_argument("--platform", required=True, choices=PLATFORMS) + gather.add_argument("--version", required=True) + for name in ("metadata", "verify"): + item = sub.add_parser(name) + item.add_argument("--directory", required=True, type=Path) + item.add_argument("--version", required=True) + item.add_argument("--tag", required=True) + if name == "metadata": + item.add_argument("--date", required=True) + item.add_argument("--notes", default="") + args = parser.parse_args() + root = args.root.resolve() + if args.command == "preflight": + preflight(root, args.version, args.tag, args.check_git, args.signing) + if args.config: + write_build_config(args.config) + elif args.command == "ci-key": + ci_key(root, args.directory) + elif args.command == "collect": + collect(root, args.bundle, args.directory, args.platform, args.version) + elif args.command == "metadata": + require(os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") != "1", "CI artifacts cannot release") + data = metadata(root, args.directory, args.version, args.tag, args.notes, args.date) + path = args.directory / "latest.json" + require( + not path.exists() and not (args.directory / "latest.json.sig").exists(), + "metadata already exists", + ) + path.write_bytes(canonical(data)) + signer(root, path) + verify_release(root, args.directory, args.version, args.tag) + else: + verify_release(root, args.directory, args.version, args.tag) + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, subprocess.SubprocessError, KeyError, TypeError) as error: + print(f"Release validation failed: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/release_assets_test.py b/scripts/release_assets_test.py new file mode 100644 index 0000000..df54e6c --- /dev/null +++ b/scripts/release_assets_test.py @@ -0,0 +1,115 @@ +"""No network: reject unsafe draft listings before invoking GitHub download.""" + +import copy +import unittest +from pathlib import Path +from unittest.mock import patch + +import release +import release_github + + +class DraftAssetGateTests(unittest.TestCase): + def draft(self): + packages = release.expected_names("0.3.0") + names = packages | {name + ".sig" for name in packages} | {"latest.json", "latest.json.sig"} + self.assertEqual(len(names), 18) + return { + "id": 123, "tag_name": "v0.3.0", "draft": True, "prerelease": False, + "assets": [{"name": name, "id": index, "state": "uploaded", "size": 1, "digest": None} + for index, name in enumerate(sorted(names), 1)], + } + + def assert_no_download(self, data): + with (patch.object(release_github, "api", return_value=data), + patch.object(release_github, "gh") as github, + patch.object(release, "verify_release") as verify): + with self.assertRaises(ValueError): + release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0") + github.assert_not_called() + verify.assert_not_called() + + def fake_download(self, listing): + def download(*args): + self.assertEqual(args[:3], ("release", "download", "v0.3.0")) + destination = Path(args[args.index("--dir") + 1]) + for asset in listing["assets"]: + (destination / asset["name"]).write_bytes(b"x" * asset["size"]) + return download + + def test_exact_complete_listing_still_downloads_and_verifies_actual_contents(self): + listing = self.draft() + with (patch.object(release_github, "api", side_effect=[listing, listing]) as api, + patch.object(release_github, "gh", side_effect=self.fake_download(listing)) as github, + patch.object(release, "verify_release") as verify): + self.assertEqual(release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0"), listing) + github.assert_called_once() + verify.assert_called_once() + self.assertEqual(api.call_count, 2) + + def test_unexpected_traversal_absolute_and_excessive_names_cannot_download(self): + for name in ["extra.exe", "../latest.json", r"..\latest.json", "/tmp/latest.json", + "folder/latest.json", "latest.JSON", "x" * 4096]: + with self.subTest(name=name[:50]): + listing = self.draft() + listing["assets"][0]["name"] = name + self.assert_no_download(listing) + listing = self.draft() + listing["assets"].append({"name": "extra.txt", "id": 100, "state": "uploaded", "size": 1}) + self.assert_no_download(listing) + + def test_missing_duplicate_and_unfinished_files_cannot_download(self): + for index in range(18): + with self.subTest(missing=index): + listing = self.draft() + listing["assets"].pop(index) + self.assert_no_download(listing) + listing = self.draft() + listing["assets"].append(copy.deepcopy(listing["assets"][0])) + self.assert_no_download(listing) + listing = self.draft() + listing["assets"][0]["state"] = "new" + self.assert_no_download(listing) + + def test_every_asset_size_must_be_a_positive_integer_within_its_limit(self): + listing = self.draft() + for index, asset in enumerate(listing["assets"]): + limit = 32768 if asset["name"] == "latest.json" else 8192 if asset["name"].endswith(".sig") else 1024**3 + for size in [0, -1, True, False, 1.0, "1", None, limit + 1]: + with self.subTest(name=asset["name"], size=size): + altered = copy.deepcopy(listing) + altered["assets"][index]["size"] = size + self.assert_no_download(altered) + + def test_declared_size_boundaries_are_accepted_without_allocating_large_files(self): + listing = self.draft() + for asset in listing["assets"]: + asset["size"] = 32768 if asset["name"] == "latest.json" else 8192 if asset["name"].endswith(".sig") else 1024**3 + with (patch.object(release_github, "api", return_value=listing), + patch.object(release_github, "gh", side_effect=RuntimeError("download boundary reached")) as github): + with self.assertRaisesRegex(RuntimeError, "download boundary reached"): + release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0") + github.assert_called_once() + + def test_valid_listing_does_not_bypass_signature_failure_or_remote_race_checks(self): + listing = self.draft() + with (patch.object(release_github, "api", return_value=listing) as api, + patch.object(release_github, "gh", side_effect=self.fake_download(listing)) as github, + patch.object(release, "verify_release", side_effect=ValueError("invalid updater signature")) as verify): + with self.assertRaisesRegex(ValueError, "invalid updater signature"): + release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0") + github.assert_called_once() + verify.assert_called_once() + self.assertEqual(api.call_count, 1) + changed = copy.deepcopy(listing) + changed["assets"][0]["id"] += 100 + with (patch.object(release_github, "api", side_effect=[listing, changed]), + patch.object(release_github, "gh", side_effect=self.fake_download(listing)), + patch.object(release, "verify_release") as verify): + with self.assertRaisesRegex(ValueError, "changed during verification"): + release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0") + verify.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_formats.py b/scripts/release_formats.py new file mode 100644 index 0000000..b7ab10d --- /dev/null +++ b/scripts/release_formats.py @@ -0,0 +1,237 @@ +"""Read-only package checks. Never execute or install a package under inspection.""" + +import io +import json +import plistlib +import shutil +import struct +import subprocess +import tarfile +from pathlib import Path, PurePosixPath + + +def require(condition, message): + if not condition: + raise ValueError(f"Package validation failed: {message}") + + +def pe_machine(data): + require(len(data) >= 64 and data[:2] == b"MZ", "invalid PE DOS header") + offset = struct.unpack_from("\n", "invalid deb ar header") + seen = set() + while header := stream.read(60): + require(len(header) == 60 and header[58:] == b"`\n", "invalid deb ar member") + name = header[:16].decode("ascii").strip().removesuffix("/") + require(name not in seen and len(seen) < 20, "duplicate/excessive deb members") + seen.add(name) + size = int(header[48:58].decode("ascii").strip()) + require( + 0 <= size <= 1024**3 and stream.tell() + size <= path.stat().st_size, + "invalid/truncated deb member size", + ) + if name == "debian-binary": + require(size <= 16, "invalid debian-binary size") + debian_binary = stream.read(size) + elif name in {"control.tar.gz", "control.tar.xz", "control.tar"}: + require(control is None and size <= 8 * 1024**2, "invalid deb control archive") + control = stream.read(size) + require(len(control) == size, "truncated deb control archive") + else: + stream.seek(size, 1) + if size % 2: + require(stream.read(1) == b"\n", "invalid ar padding") + require( + debian_binary == b"2.0\n" + and control is not None + and any(name.startswith("data.tar") for name in seen), + "missing deb version/control/data", + ) + with tarfile.open(fileobj=io.BytesIO(control), mode="r:*") as archive: + matches = [item for item in archive if safe_member(item.name) == "control"] + require( + len(matches) == 1 and matches[0].isfile() and matches[0].size <= 1024**2, + "invalid deb control file", + ) + text = archive.extractfile(matches[0]).read().decode("utf-8") + fields = {} + for line in text.splitlines(): + if line and not line[0].isspace(): + key, separator, value = line.partition(":") + require(separator and key not in fields, "invalid/duplicate deb control field") + fields[key] = value.strip() + require(fields.get("Architecture") == "amd64", "deb Architecture must be amd64") + require(fields.get("Version") == version, "deb version mismatch") + + +def expected_nsis_payload(original): + # tauri-cli-v2.11.4 / tauri-bundler::patch_binary changes only the first + # complete token, then restores the original on-disk main after each bundle. + # Reproduce that exact operation; never mask PE checksums, sections, + # Authenticode certificate tables or arbitrary matching regions. + token = b"__TAURI_BUNDLE_TYPE_VAR_UNK" + patched = b"__TAURI_BUNDLE_TYPE_VAR_NSS" + offset = original.find(token) + require(offset >= 0, "built launcher lacks the expected Tauri bundle-type marker") + return original[:offset] + patched + original[offset + len(token) :] + + +def nsis_payload(path, built_main): + tool = shutil.which("7z") or r"C:\Program Files\7-Zip\7z.exe" + require(Path(tool).is_file(), "7-Zip is required to inspect NSIS payload") + listing = subprocess.run( + [tool, "l", "-slt", "-sccUTF-8", "--", str(path)], + capture_output=True, + check=False, + timeout=60, + ) + require(listing.returncode == 0, "7-Zip cannot inspect NSIS payload") + matches = [] + for line in listing.stdout.decode("utf-8", errors="strict").splitlines(): + if line.startswith("Path = "): + entry = line.removeprefix("Path = ") + if PurePosixPath(entry.replace("\\", "/")).name == built_main.name: + matches.append(entry) + require(len(matches) == 1, "expected exactly one bundled launcher EXE in NSIS") + extracted = subprocess.run( + [tool, "x", "-so", "-bd", "--", str(path), matches[0]], + capture_output=True, + check=False, + timeout=60, + ) + require(extracted.returncode == 0, "7-Zip cannot extract NSIS launcher for inspection") + pe_x64(extracted.stdout) + require( + extracted.stdout == expected_nsis_payload(built_main.read_bytes()), + "NSIS payload differs from the x64 build plus the exact Tauri NSIS marker patch", + ) + + +def windows(path, suffix, version, built_main): + with path.open("rb") as stream: + header = stream.read(4096) + if suffix == ".msi": + require( + len(header) >= 512 + and header[:8] == bytes.fromhex("d0cf11e0a1b11ae1") + and header[28:30] == b"\xfe\xff", + "invalid MSI compound-file header", + ) + else: + # Tauri's NSIS x64 package legitimately uses an x86-unicode installer stub. + require( + pe_machine(header) in {(0x14C, 0x10B), (0x8664, 0x20B)}, "unsupported NSIS PE wrapper" + ) + if built_main is None: + return # Full payload/COM checks run on the Windows collection runner. + require(built_main.is_file() and not built_main.is_symlink(), "built launcher EXE is missing") + pe_x64(built_main.read_bytes()) + if suffix != ".msi": + nsis_payload(path, built_main) + else: + script = Path(__file__).with_name("release_msi.ps1") + result = subprocess.run( + [ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-File", + str(script), + "-PackagePath", + str(path.resolve()), + ], + capture_output=True, + check=False, + timeout=60, + ) + require(result.returncode == 0, "cannot read MSI summary/properties") + details = json.loads(result.stdout.decode("utf-8-sig")) + require(details.get("template", "").split(";")[0] == "x64", "MSI template must be x64") + require(details.get("version") == version, "MSI ProductVersion mismatch") + + +def validate(path, platform, suffix, version, built_main=None): + if suffix == ".AppImage": + appimage(path) + elif suffix == ".deb": + deb(path, version) + elif suffix == ".app.tar.gz": + mac_app(path, platform, version) + elif suffix == ".dmg": + with path.open("rb") as stream: + require(path.stat().st_size >= 512, "truncated DMG") + stream.seek(-512, 2) + require(stream.read(4) == b"koly", "invalid DMG UDIF trailer") + else: + windows(path, suffix, version, built_main) diff --git a/scripts/release_gate_test.py b/scripts/release_gate_test.py new file mode 100644 index 0000000..c3291c4 --- /dev/null +++ b/scripts/release_gate_test.py @@ -0,0 +1,127 @@ +"""Execute the actual trusted workflow gate against disposable local Git history.""" + +import os +import re +import subprocess +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(os.environ.get("RELEASE_TEST_ROOT", Path(__file__).resolve().parents[1])) +WORKFLOWS = [ROOT / ".github/workflows" / name for name in ("release.yml", "release-publish.yml")] +GATE = " - name: Resolve tag using trusted workflow Git commands\n" + + +def workflow_gate(workflow): + after = workflow.read_text().split(GATE, 1)[1] + lines = after.split(" run: |\n", 1)[1].splitlines() + body = [] + for line in lines: + if not line.startswith(" "): + break + body.append(line[10:]) + return "\n".join(body) + "\n" + + +class ReleaseGateTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="shacraft-source-gate-") + self.directory = Path(self.temporary.name) + self.environment = dict(os.environ, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) + self.git("init", "--initial-branch=main") + self.git("config", "user.email", "test@example.invalid") + self.git("config", "user.name", "Release Gate Test") + (self.directory / "reviewed.txt").write_text("Reviewed source\n") + self.git("add", ".") + self.git("commit", "-m", "Reviewed main commit") + self.good = self.git("rev-parse", "HEAD") + self.git("update-ref", "refs/remotes/origin/main", self.good) + self.git("tag", "v0.2.0") + self.git("checkout", "-b", "unreviewed") + (self.directory / "scripts").mkdir() + # This validator would falsely accept its own tag if a workflow ran it. + (self.directory / "scripts/release.py").write_text( + "from pathlib import Path\nPath('untrusted-code-ran').write_text('bypassed')\n" + ) + self.git("add", ".") + self.git("commit", "-m", "Unreviewed tag with false validator") + self.bad = self.git("rev-parse", "HEAD") + self.git("tag", "v9.9.9") + self.git("checkout", "main") + + def tearDown(self): + self.temporary.cleanup() + + def git(self, *args): + return subprocess.check_output(["git", *args], cwd=self.directory, + env=self.environment, text=True, stderr=subprocess.DEVNULL).strip() + + def run_gate(self, workflow, tag): + output = self.directory / (workflow.stem + "-output") + output.unlink(missing_ok=True) + result = subprocess.run(["bash", "-c", workflow_gate(workflow)], cwd=self.directory, + env=dict(self.environment, RELEASE_TAG=tag, GITHUB_OUTPUT=str(output)), + capture_output=True, text=True, check=False) + return result, output.read_text() if output.exists() else "" + + def test_main_tag_emits_immutable_commit(self): + for workflow in WORKFLOWS: + with self.subTest(workflow=workflow.name): + result, output = self.run_gate(workflow, "v0.2.0") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(output, f"commit={self.good}\n") + + def test_unreviewed_tag_cannot_replace_its_own_ancestry_validator(self): + for workflow in WORKFLOWS: + with self.subTest(workflow=workflow.name): + result, output = self.run_gate(workflow, "v9.9.9") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(output, "") + self.assertFalse((self.directory / "untrusted-code-ran").exists()) + + def test_missing_or_retargeted_tag_cannot_change_validated_source(self): + for workflow in WORKFLOWS: + with self.subTest(workflow=workflow.name): + result, output = self.run_gate(workflow, "v8.8.8") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(output, "") + self.git("tag", "-f", "v0.2.0", self.good) + result, output = self.run_gate(workflow, "v0.2.0") + self.assertEqual(result.returncode, 0, result.stderr) + self.git("tag", "-f", "v0.2.0", self.bad) + self.assertEqual(output, f"commit={self.good}\n") + result, fresh_output = self.run_gate(workflow, "v0.2.0") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(fresh_output, "") + + def test_workflows_execute_candidate_code_only_after_gate_and_checkout_sha(self): + for workflow in WORKFLOWS: + with self.subTest(workflow=workflow.name): + text = workflow.read_text() + preflight = text.split(" preflight:\n", 1)[1].split("\n build:\n", 1)[0].split("\n publish:\n", 1)[0] + before, after = preflight.split(GATE, 1) + self.assertEqual(before.count("uses: actions/checkout@v4"), 1) + self.assertIn("fetch-depth: 0", before) + self.assertNotIn("ref:", before) # Initial checkout is trusted workflow main. + self.assertIn("release.version_tag", before) + self.assertLess(after.index("git merge-base --is-ancestor"), after.index("uses: actions/checkout@v4")) + self.assertIn("ref: ${{ steps.source.outputs.commit }}", after) + self.assertNotIn("ref: ${{ inputs.tag }}", text) + downstream = text[len(text.split(" preflight:\n", 1)[0]) + len(" preflight:\n") + len(preflight):] + self.assertGreater(downstream.count("ref: ${{ needs.preflight.outputs.commit }}"), 0) + self.assertEqual(downstream.count("uses: actions/checkout@v4"), downstream.count("ref: ${{ needs.preflight.outputs.commit }}")) + self.assertEqual(text.count("uses: actions/checkout@v4"), text.count("persist-credentials: false")) + + def test_write_token_is_only_exposed_to_explicit_final_publish_step(self): + text = WORKFLOWS[1].read_text().split("\n publish:\n", 1)[1] + job_environment = re.search(r"(?m)^ env:\n((?: .*\n)+)", text) + self.assertIsNotNone(job_environment) + self.assertNotIn("GH_TOKEN", job_environment.group(1)) + before, final = text.split(" - name: Re-download, verify signatures/metadata/assets and explicitly publish\n", 1) + self.assertNotIn("GH_TOKEN", before) + self.assertIn("GH_TOKEN: ${{ github.token }}", final) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_github.py b/scripts/release_github.py new file mode 100644 index 0000000..9f11e44 --- /dev/null +++ b/scripts/release_github.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Explicit workflow-only GitHub release operations, with fail-closed gates.""" + +import argparse +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import release + +ENVIRONMENTS = {"launcher-release", "launcher-release-publish"} + + +def gh(*args, missing=False): + result = subprocess.run(["gh", *args], capture_output=True, text=True, timeout=900, check=False) + if result.returncode: + try: + body = json.loads(result.stdout) + except ValueError: + body = {} + if missing and str(body.get("status")) == "404": + return None + raise ValueError("GitHub operation failed; no permission or validation bypass is allowed") + return result.stdout + + +def api(path, missing=False): + value = gh("api", f"repos/{release.REPOSITORY}/{path}", missing=missing) + return None if value is None else json.loads(value) + + +def validate_environment(data): + release.require(isinstance(data, dict), "protected environment is absent") + policy = data.get("deployment_branch_policy") or {} + release.require( + policy.get("protected_branches") is True and policy.get("custom_branch_policies") is False, + "release environment must allow protected branches only", + ) + rules = data.get("protection_rules") or [] + reviewers = next((rule for rule in rules if rule.get("type") == "required_reviewers"), {}) + release.require( + reviewers.get("prevent_self_review") is True, "environment must prevent self review" + ) + allowed = reviewers.get("reviewers") or [] + release.require( + any( + item.get("type") in {"User", "Team"} + and type((item.get("reviewer") or {}).get("id")) is int + and item["reviewer"]["id"] > 0 + for item in allowed + ), + "release environment requires an independent reviewer", + ) + + +def workflow_guard(): + release.require( + os.environ.get("GITHUB_REPOSITORY") == release.REPOSITORY, "wrong release repository" + ) + release.require( + os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch", + "release must be dispatched manually", + ) + release.require( + os.environ.get("GITHUB_REF") == "refs/heads/main", "release workflow must run from main" + ) + + +def gate(environment): + workflow_guard() + release.require(environment in ENVIRONMENTS, "unexpected release environment") + release.require( + api("branches/main").get("protected") is True, "main must be a protected branch" + ) + validate_environment(api(f"environments/{environment}", missing=True)) + # Jobs consume this output only after validation. Never reference a missing + # environment directly: GitHub would create it without protection rules. + if os.environ.get("GITHUB_OUTPUT"): + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as stream: + stream.write(f"environment={environment}\n") + + +def asset_snapshot(data): + release.require( + data.get("draft") is True and data.get("prerelease") is False, + "expected a stable DRAFT release", + ) + assets = data.get("assets") or [] + result = {} + for asset in assets: + name = asset["name"] + release.require( + name not in result and asset.get("state") == "uploaded", + "duplicate or incomplete release asset", + ) + result[name] = (asset["id"], asset["size"], asset.get("digest")) + return result + + +def verify_draft(root, version, tag): + release.version_tag(version, tag) + before = api(f"releases/tags/{tag}") + release.require(before.get("tag_name") == tag, "draft tag mismatch") + snapshot = asset_snapshot(before) + packages = release.expected_names(version) + expected = packages | {name + ".sig" for name in packages} | {"latest.json", "latest.json.sig"} + release.require(set(snapshot) == expected, "draft asset set is incomplete or unexpected") + # Reject remote names and declared sizes before gh writes any asset locally. + # This is only a pre-download bound; signatures and actual bytes are still + # checked below, followed by the remote identity/race check. + for name, (_, size, _) in snapshot.items(): + limit = ( + 32 * 1024 if name == "latest.json" + else 8 * 1024 if name.endswith(".sig") + else release.MAX_SIZE + ) + release.require(type(size) is int and 0 < size <= limit, "invalid remote draft asset size") + with tempfile.TemporaryDirectory(prefix="shacraft-draft-check-") as temporary: + directory = Path(temporary) + gh("release", "download", tag, "--repo", release.REPOSITORY, "--dir", str(directory)) + release.verify_release(root, directory, version, tag) + release.require( + set(snapshot) == {p.name for p in directory.iterdir()}, + "draft assets changed during download", + ) + for path in directory.iterdir(): + release.require(snapshot[path.name][1] == path.stat().st_size, "draft size mismatch") + after = api(f"releases/tags/{tag}") + release.require( + after["id"] == before["id"] and asset_snapshot(after) == snapshot, + "draft changed during verification", + ) + return after + + +def draft(root, directory, version, tag): + workflow_guard() + release.preflight(root, version, tag, check_git=True, signing=True) + release.verify_release(root, directory, version, tag) + with tempfile.TemporaryDirectory(prefix="shacraft-release-notes-") as temporary: + notes = Path(temporary) / "notes.md" + release_notes = release.read_json(directory / "latest.json")["notes"] + instructions = ( + f"https://github.com/{release.REPOSITORY}/blob/{tag}/docs/updater-release.md" + "#installed-package-migration-and-recovery" + ) + notes.write_text( + release_notes + "\n\n" + + "[Установка, переход с 0.1.1 и восстановление](" + instructions + ").\n" + + "Используйте прежний тип пакета. Обновление .deb выполняется через менеджер пакетов.\n", + encoding="utf-8", + ) + gh( + "release", + "create", + tag, + *[str(path) for path in sorted(directory.iterdir())], + "--repo", + release.REPOSITORY, + "--draft", + "--verify-tag", + "--title", + f"ShaCraft Launcher {version}", + "--notes-file", + str(notes), + ) + verify_draft(root, version, tag) + print( + "Complete draft uploaded and re-verified. Publication requires the separate protected workflow." + ) + + +def publish(root, version, tag, confirmation): + workflow_guard() + release.require( + confirmation == f"publish {tag}", "explicit publication confirmation does not match tag" + ) + release.preflight(root, version, tag, check_git=True) + pinned = (root / "src-tauri/updater-public-key.txt").read_text().strip() + release.require( + release.public_key() == pinned, "publication key differs from committed updater key" + ) + gate("launcher-release-publish") # Re-check protection immediately before publication. + current = api("releases/latest", missing=True) + if current is not None: + old_tag = current.get("tag_name", "") + release.version_tag(old_tag.removeprefix("v"), old_tag) + release.require( + tuple(map(int, version.split("."))) > tuple(map(int, old_tag[1:].split("."))), + "publication must advance the stable release version", + ) + verify_draft(root, version, tag) + gh("release", "edit", tag, "--repo", release.REPOSITORY, "--draft=false", "--latest") + print("Verified release published by explicit protected operator workflow.") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + sub = parser.add_subparsers(dest="command", required=True) + item = sub.add_parser("gate") + item.add_argument("environment", choices=sorted(ENVIRONMENTS)) + for name in ("draft", "publish"): + item = sub.add_parser(name) + item.add_argument("--version", required=True) + item.add_argument("--tag", required=True) + if name == "draft": + item.add_argument("--directory", required=True, type=Path) + else: + item.add_argument("--confirmation", required=True) + args = parser.parse_args() + if args.command == "gate": + gate(args.environment) + elif args.command == "draft": + draft(args.root.resolve(), args.directory, args.version, args.tag) + else: + publish(args.root.resolve(), args.version, args.tag, args.confirmation) + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, subprocess.SubprocessError, KeyError, TypeError) as error: + print(f"Release stopped: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/release_msi.ps1 b/scripts/release_msi.ps1 new file mode 100644 index 0000000..3bbc36d --- /dev/null +++ b/scripts/release_msi.ps1 @@ -0,0 +1,14 @@ +param([Parameter(Mandatory=$true)][string]$PackagePath) +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +# MSIDBOPEN_READONLY = 0. Reading COM properties never installs the package. +$installer = New-Object -ComObject WindowsInstaller.Installer +$summary = $installer.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $installer, @($PackagePath, 0)) +$template = $summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) +$database = $installer.GetType().InvokeMember('OpenDatabase', 'InvokeMethod', $null, $installer, @($PackagePath, 0)) +$view = $database.GetType().InvokeMember('OpenView', 'InvokeMethod', $null, $database, @('SELECT `Value` FROM `Property` WHERE `Property` = ''ProductVersion''')) +$view.GetType().InvokeMember('Execute', 'InvokeMethod', $null, $view, $null) | Out-Null +$record = $view.GetType().InvokeMember('Fetch', 'InvokeMethod', $null, $view, $null) +if ($null -eq $record) { throw 'Missing MSI ProductVersion' } +$version = $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @(1)) +@{ template = [string]$template; version = [string]$version } | ConvertTo-Json -Compress diff --git a/scripts/release_test.py b/scripts/release_test.py new file mode 100644 index 0000000..eb7e7f3 --- /dev/null +++ b/scripts/release_test.py @@ -0,0 +1,531 @@ +"""Real Tauri signature checks using disposable keys, never release credentials.""" + +import io +import json +import os +import plistlib +import shutil +import struct +import subprocess +import tarfile +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import release +from release_assets_test import DraftAssetGateTests # noqa: F401; unittest discovery +from release_gate_test import ReleaseGateTests # noqa: F401; unittest discovery +import release_formats +import release_github + +ROOT = Path(os.environ.get("RELEASE_TEST_ROOT", Path(__file__).resolve().parents[1])) + + +def fixture_pe(machine=0x8664, magic=0x20B): + data = bytearray(256) + data[:2] = b"MZ" + struct.pack_into("\n") + for name, data in [ + ("debian-binary", b"2.0\n"), + ("control.tar.gz", control), + ("data.tar.gz", fixture_tar({})), + ]: + header = f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{100644:<8}{len(data):<10}`\n".encode() + assert len(header) == 60 + result.extend(header + data + (b"\n" if len(data) % 2 else b"")) + return bytes(result) + + +def fixture_package(name, version="0.3.0"): + if name.endswith(".app.tar.gz"): + cpu = 0x0100000C if "aarch64" in name else 0x01000007 + executable = struct.pack(", ) -> Result<(), String> { let directory = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&directory, &state)?; let permit = state.account.acquire("Account operation")?; tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; let _permit = permit; let login = || -> Result { let client = msa::http_client().map_err(|error| error.to_string())?; @@ -76,8 +78,10 @@ pub(crate) async fn get_account( state: State<'_, LauncherOperations>, ) -> Result, String> { let data_dir = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?; let permit = state.account.acquire("Account operation")?; tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; let _permit = permit; let Some(refresh_token) = msa::load_refresh_token(&data_dir) else { return Ok(None); @@ -102,8 +106,10 @@ pub(crate) async fn logout( state: State<'_, LauncherOperations>, ) -> Result<(), String> { let data_dir = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?; let permit = state.account.acquire("Account operation")?; tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; let _permit = permit; msa::clear_account(&data_dir) }) diff --git a/src-tauri/src/commands/game.rs b/src-tauri/src/commands/game.rs index 267c25d..f6e3175 100644 --- a/src-tauri/src/commands/game.rs +++ b/src-tauri/src/commands/game.rs @@ -1,7 +1,7 @@ -use super::{data_dir, shacraft::resolve_identity}; +use super::{data_dir, profiles::ProfileMetadata, shacraft::resolve_identity}; use crate::{ - java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime, - settings, + installation_lock::InstallationLock, java, launch, manifest, mojang, neoforge, + operations::LauncherOperations, profile, remote, runtime, session, settings, shacraft_account, }; use reqwest::blocking::Client; use serde::Serialize; @@ -15,132 +15,158 @@ pub(crate) struct InstallProgress { current_bytes: u64, total_bytes: u64, } +fn progress(app: &AppHandle, stage: &'static str) -> mojang::ProgressCallback { + let app = app.clone(); + Arc::new(move |current, total| { + let _ = app.emit( + "game-install-progress", + InstallProgress { + stage, + current_bytes: current, + total_bytes: total, + }, + ); + }) +} -/// Resolves the vanilla + (if any) loader version JSONs for `manifest` and -/// merges them, ensuring a Java runtime and (for NeoForge profiles) running -/// the installer along the way. Shared by `ensure_game_installed` and -/// `launch_game` so both always agree on exactly what "installed" means. -/// `on_progress` is forwarded to the NeoForge installer when one runs; -/// callers that don't display progress (e.g. `launch_game`, which only -/// hits this after `ensure_game_installed` already installed everything) -/// pass a no-op callback. fn resolve_merged_version( client: &Client, manifest: &manifest::Manifest, - java_executable: &Path, + java: &Path, game_dir: &Path, cache_dir: &Path, on_progress: &mojang::ProgressCallback, ) -> Result { - let mojang_manifest = - mojang::fetch_version_manifest(client).map_err(|error| error.to_string())?; - let vanilla_entry = mojang::find_version(&mojang_manifest, &manifest.minecraft.version) - .ok_or_else(|| { - format!( - "Mojang does not list Minecraft version {}", - manifest.minecraft.version - ) - })?; - let vanilla = - mojang::fetch_version_json(client, vanilla_entry).map_err(|error| error.to_string())?; - + let catalog = mojang::fetch_version_manifest(client).map_err(|e| e.to_string())?; + let entry = mojang::find_version(&catalog, &manifest.minecraft.version) + .ok_or_else(|| format!("Mojang does not list {}", manifest.minecraft.version))?; + let vanilla = mojang::fetch_version_json(client, entry).map_err(|e| e.to_string())?; + // The installer may reuse an existing vanilla JAR without verifying it. + // Establish provider integrity BEFORE any NeoForge processor uses that input. + mojang::ensure_client_jar( + client, + game_dir, + &vanilla.id, + &vanilla + .downloads + .as_ref() + .ok_or("Vanilla client download metadata is missing")? + .client, + ) + .map_err(|e| e.to_string())?; if manifest.minecraft.loader.kind == "neoforge" { - let installer_client = neoforge::http_client().map_err(|error| error.to_string())?; - let neoforge_version = neoforge::ensure_client_installed( + let installer_client = neoforge::http_client().map_err(|e| e.to_string())?; + let loader = neoforge::ensure_client_installed( &installer_client, - java_executable, + java, game_dir, cache_dir, &manifest.minecraft.loader.version, + &vanilla, on_progress, ) - .map_err(|error| error.to_string())?; - mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string()) + .map_err(|e| e.to_string())?; + mojang::merge_versions(&vanilla, Some(&loader)).map_err(|e| e.to_string()) } else { - mojang::merge_versions(&vanilla, None).map_err(|error| error.to_string()) + mojang::merge_versions(&vanilla, None).map_err(|e| e.to_string()) } } -/// Downloads and installs everything needed to run `profile_id`: the -/// exact Minecraft/loader version the ShaCraft-signed manifest specifies, -/// a Java runtime if none is already usable, and game assets. Emits -/// `game-install-progress` throughout with real progress for every stage: -/// download bytes for Java, installer-confirmed library/processor counts -/// for NeoForge, and download bytes for libraries/assets. +/// Internal stages receive the same verified snapshot; none can refetch it. +fn prepare( + directory: &Path, + snapshot: &remote::VerifiedSnapshot, + progress: &impl Fn(&'static str) -> mojang::ProgressCallback, +) -> Result< + ( + mojang::MergedVersion, + java::JavaInstallation, + profile::ProfileInspection, + ), + String, +> { + let manifest = &snapshot.manifest; + let root = directory.join("profiles").join(&manifest.id); + progress("mods")(0, 0); + profile::sync_snapshot(&root, snapshot).map_err(|e| e.to_string())?; + let inspection = profile::inspect(&root, manifest).map_err(|e| e.to_string())?; + if !inspection.up_to_date { + return Err( + "Синхронизация не завершена; запуск остановлен. Проверьте конфликты файлов.".into(), + ); + } + let game_dir = directory.join("game"); + let client = mojang::http_client().map_err(|e| e.to_string())?; + let runtime_client = runtime::http_client().map_err(|e| e.to_string())?; + let java = java::ensure_java( + &runtime_client, + &game_dir.join("runtime"), + manifest.minecraft.java_major, + &progress("java"), + ) + .map_err(|e| e.to_string())?; + let merged = resolve_merged_version( + &client, + manifest, + Path::new(&java.executable), + &game_dir, + &game_dir.join("cache"), + &progress("neoforge"), + )?; + let libraries_client = mojang::library_http_client().map_err(|e| e.to_string())?; + mojang::ensure_client_jar( + &client, + &game_dir, + &merged.client_jar_version_id, + &merged.client, + ) + .map_err(|e| e.to_string())?; + mojang::ensure_libraries( + &libraries_client, + &game_dir, + &merged.libraries, + &progress("libraries"), + ) + .map_err(|e| e.to_string())?; + let index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index) + .map_err(|e| e.to_string())?; + mojang::ensure_assets(&client, &game_dir, &index, &progress("assets")) + .map_err(|e| e.to_string())?; + Ok((merged, java, inspection)) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PreparationResult { + inspection: profile::ProfileInspection, + metadata: ProfileMetadata, + onboarding: Option, +} + +/// Full repair, using one snapshot and one writer lock for mods AND the game. #[tauri::command] pub(crate) async fn ensure_game_installed( app: AppHandle, state: State<'_, LauncherOperations>, profile_id: String, -) -> Result<(), String> { - let game_dir = data_dir(&app)?.join("game"); - let runtime_root = game_dir.join("runtime"); - let cache_dir = game_dir.join("cache"); +) -> Result { + let directory = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&directory, &state)?; let permit = state.installation.acquire("Installation")?; - - tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; let _permit = permit; - let client = mojang::http_client().map_err(|error| error.to_string())?; - let runtime_client = runtime::http_client().map_err(|error| error.to_string())?; - let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?; - - let stage_progress = |stage: &'static str| -> mojang::ProgressCallback { - let app = app.clone(); - Arc::new(move |current, total| { - let _ = app.emit( - "game-install-progress", - InstallProgress { - stage, - current_bytes: current, - total_bytes: total, - }, - ); - }) - }; - - let java_install = java::ensure_java( - &runtime_client, - &runtime_root, - manifest.minecraft.java_major, - &stage_progress("java"), - ) - .map_err(|error| error.to_string())?; - - let merged = resolve_merged_version( - &client, - &manifest, - Path::new(&java_install.executable), - &game_dir, - &cache_dir, - &stage_progress("neoforge"), - )?; - // NeoForge may leave vanilla runtime libraries (including LWJGL) absent. - // Verify the full merged set, using the loader Maven only for libraries. - let library_client = mojang::library_http_client().map_err(|error| error.to_string())?; - mojang::ensure_client_jar( - &client, - &game_dir, - &merged.client_jar_version_id, - &merged.client, - ) - .map_err(|error| error.to_string())?; - mojang::ensure_libraries( - &library_client, - &game_dir, - &merged.libraries, - &stage_progress("libraries"), - ) - .map_err(|error| error.to_string())?; - - let asset_index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index) - .map_err(|error| error.to_string())?; - mojang::ensure_assets(&client, &game_dir, &asset_index, &stage_progress("assets")) - .map_err(|error| error.to_string())?; - - Ok(()) + let _lock = InstallationLock::acquire(&directory)?; + let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?; + let (_, _, inspection) = prepare(&directory, &snapshot, &|stage| progress(&app, stage))?; + Ok(PreparationResult { + inspection, + metadata: (&snapshot).into(), + onboarding: None, + }) }) .await - .map_err(|error| format!("Install task failed: {error}"))? + .map_err(|e| e.to_string())? } #[derive(Clone, Serialize)] @@ -150,83 +176,143 @@ pub(crate) struct GameExited { exit_code: Option, } -/// Launches `profile_id` with the verified ShaCraft account's linked nickname. -/// Local legacy nickname/account-mode preferences cannot override the link. -/// Spawns the game detached; watches it on a -/// background thread only to emit `game-exited` when it eventually closes. +async fn play( + app: AppHandle, + state: &LauncherOperations, + profile_id: String, + onboarding_name: Option, +) -> Result { + let directory = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&directory, &state)?; + let permit = state.installation.acquire("Installation")?; + let account_operation = state.shacraft_account.clone(); + tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; + let _permit = permit; + let lock = InstallationLock::acquire(&directory)?; + let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?; + if onboarding_name.is_some() && (profile_id != "aeronautics" || !snapshot.manifest.files.iter().any(|f| + f.path.starts_with("mods/shacraft-game-bridge-") && f.path.ends_with(".jar") && f.policy == manifest::FilePolicy::Managed)) { + return Err("Опубликованная сборка ещё не поддерживает первый вход. Нужен подписанный мод ShaCraft Game Bridge.".into()); + } + let (merged, java, inspection) = prepare(&directory, &snapshot, &|stage| progress(&app, stage))?; + // Refresh identity AFTER downloads; no long-lived cached permission. + let grant = if let Some(name) = onboarding_name { + let _account = account_operation.acquire("ShaCraft account operation")?; + let grant = shacraft_account::start_onboarding(&directory, &name).map_err(|e| e.to_string())?; + shacraft_account::validate_onboarding(&directory, &grant).map_err(|e| e.to_string())?; + Some(grant) + } else { None }; + let identity = if let Some(grant) = &grant { + session::PlayerIdentity::Offline { name: grant.challenge.mc_username.clone() } + } else { resolve_identity(&directory, &account_operation)? }; + let preferences = settings::load(&directory).map_err(|e| e.to_string())?; + let logs = directory.join("logs"); + std::fs::create_dir_all(&logs).map_err(|e| e.to_string())?; + let timestamp = SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(); + let game_dir = directory.join("game"); + let profile_dir = directory.join("profiles").join(&snapshot.manifest.id); + let log_path = logs.join(format!("{profile_id}-{timestamp}.log")); + let request = launch::LaunchRequest { java_executable: Path::new(&java.executable), game_dir: &game_dir, + profile_dir: &profile_dir, merged: &merged, identity: &identity, memory_mb: preferences.memory_mb, + log_path: &log_path, onboarding_token: grant.as_ref().map(|g|g.grant_token.as_str()) }; + progress(&app, "launch")(0,0); + lock.starting()?; + let mut child = match launch::launch(&request) { + Ok(child) => child, + Err(error) => { lock.finished()?; return Err(error.to_string()); } + }; + // Failure to record a living child, including an immediate exit, + // terminates and waits for it before releasing the writer lock. + if let Err(error) = lock.running(child.id()) { + let _ = child.kill(); + if child.wait().is_ok() { let _ = lock.finished(); } + return Err(error); + } + let watch_app = app.clone(); + std::thread::spawn(move || { + let exit = child.wait(); + if exit.is_ok() { let _ = lock.finished(); } + let _ = watch_app.emit("game-exited", GameExited {profile_id,exit_code: exit.ok().and_then(|s|s.code())}); + drop(lock); + }); + Ok(PreparationResult { inspection, metadata: (&snapshot).into(), onboarding: grant.map(|g|g.challenge) }) + }).await.map_err(|e|e.to_string())? +} + +/// Legacy command also performs the entire preparation; no public IPC can skip +/// reconciliation or substitute a fresh manifest between install and launch. #[tauri::command] pub(crate) async fn launch_game( app: AppHandle, state: State<'_, LauncherOperations>, profile_id: String, -) -> Result<(), String> { - let game_dir = data_dir(&app)?.join("game"); - let data_dir = data_dir(&app)?; - let permit = state.installation.acquire("Installation")?; - let account_operation = state.shacraft_account.clone(); +) -> Result { + play(app, &state, profile_id, None).await +} +#[tauri::command] +pub(crate) async fn launch_onboarding( + app: AppHandle, + state: State<'_, LauncherOperations>, + profile_id: String, + nickname: String, +) -> Result { + if !shacraft_account::valid_nickname(&nickname) { + return Err("Неверный игровой ник".into()); + } + play(app, &state, profile_id, Some(nickname)).await +} - tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { - let _permit = permit; - let client = mojang::http_client().map_err(|error| error.to_string())?; - let runtime_client = runtime::http_client().map_err(|error| error.to_string())?; - let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?; - let profile_dir = data_dir.join("profiles").join(&manifest.id); - let settings = settings::load(&data_dir).map_err(|error| error.to_string())?; - let identity = resolve_identity(&data_dir, &account_operation)?; +#[cfg(test)] +mod tests { + use super::*; - // Everything here should already be installed by `ensure_game_installed`, - // so these are expected to hit their fast paths; no progress to show. - let no_progress: mojang::ProgressCallback = Arc::new(|_, _| {}); - let java_install = java::ensure_java( - &runtime_client, - &game_dir.join("runtime"), - manifest.minecraft.java_major, - &no_progress, - ) - .map_err(|error| error.to_string())?; - let merged = resolve_merged_version( - &client, - &manifest, - Path::new(&java_install.executable), - &game_dir, - &game_dir.join("cache"), - &no_progress, - )?; - - let timestamp = SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let log_dir = data_dir.join("logs"); - std::fs::create_dir_all(&log_dir).map_err(|error| error.to_string())?; - let log_path = log_dir.join(format!("{profile_id}-{timestamp}.log")); - - let request = launch::LaunchRequest { - java_executable: Path::new(&java_install.executable), - game_dir: &game_dir, - profile_dir: &profile_dir, - merged: &merged, - identity: &identity, - memory_mb: settings.memory_mb, - log_path: &log_path, + /// Real provider downloads and installer execution; never logs in or joins + /// a server. Artifacts stay in a fresh temporary directory for diagnosis. + #[test] + #[ignore = "downloads the real pack/game and executes the official installer; needs network and Java 21"] + fn live_cold_install_and_corruption_repair() { + let directory = std::env::temp_dir().join(format!( + "shacraft-cold-install-{}", + SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + assert!(!directory.exists()); + eprintln!("Isolated installation: {}", directory.display()); + let _lock = InstallationLock::acquire(&directory).unwrap(); + let snapshot = remote::fetch_snapshot("aeronautics").unwrap(); + let callback = |stage| -> mojang::ProgressCallback { + eprintln!("Stage: {stage}"); + Arc::new(|_, _| {}) }; - let mut child = launch::launch(&request).map_err(|error| error.to_string())?; - - let watch_app = app.clone(); - let watch_profile_id = profile_id.clone(); - std::thread::spawn(move || { - let exit_code = child.wait().ok().and_then(|status| status.code()); - let _ = watch_app.emit( - "game-exited", - GameExited { - profile_id: watch_profile_id, - exit_code, - }, - ); - }); - - Ok(()) - }) - .await - .map_err(|error| format!("Launch task failed: {error}"))? + let (_, java, inspection) = prepare(&directory, &snapshot, &callback).unwrap(); + assert!(inspection.up_to_date); + assert_eq!(java.major, snapshot.manifest.minecraft.java_major); + let game = directory.join("game"); + let version = &snapshot.manifest.minecraft.loader.version; + let json = neoforge::installed_version_json_path(&game, version); + let jar = game.join(format!( + "libraries/net/neoforged/neoforge/{version}/neoforge-{version}-client.jar" + )); + let original_json = std::fs::read(&json).unwrap(); + std::fs::write(&json, b"nonempty corrupted version JSON").unwrap(); + std::fs::write(&jar, b"nonempty corrupted patched JAR").unwrap(); + let (_, _, repaired) = prepare(&directory, &snapshot, &callback).unwrap(); + assert!(repaired.up_to_date); + assert_eq!(std::fs::read(&json).unwrap(), original_json); + assert!(std::fs::metadata(&jar).unwrap().len() > 1024); + // A third preparation verifies the receipt and all downloads again. + assert!( + prepare(&directory, &snapshot, &callback) + .unwrap() + .2 + .up_to_date + ); + eprintln!( + "Cold install, corrupt JSON/JAR repair and healthy recheck passed: {}", + directory.display() + ); + } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6f82e5a..9d9db7e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod host; pub(crate) mod preferences; pub(crate) mod profiles; pub(crate) mod shacraft; +pub(crate) mod updater; use std::path::PathBuf; use tauri::{AppHandle, Manager}; diff --git a/src-tauri/src/commands/preferences.rs b/src-tauri/src/commands/preferences.rs index 1979170..2f61fb4 100644 --- a/src-tauri/src/commands/preferences.rs +++ b/src-tauri/src/commands/preferences.rs @@ -1,6 +1,6 @@ use super::data_dir; -use crate::settings; -use tauri::AppHandle; +use crate::{operations::LauncherOperations, settings}; +use tauri::{AppHandle, State}; #[tauri::command] pub(crate) async fn load_settings(app: AppHandle) -> Result { @@ -14,11 +14,16 @@ pub(crate) async fn load_settings(app: AppHandle) -> Result, settings: settings::LauncherSettings, ) -> Result { let data_dir = data_dir(&app)?; - tauri::async_runtime::spawn_blocking(move || settings::save(&data_dir, settings)) - .await - .map_err(|error| format!("Settings task failed: {error}"))? - .map_err(|error| error.to_string()) + let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?; + tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; + settings::save(&data_dir, settings) + }) + .await + .map_err(|error| format!("Settings task failed: {error}"))? + .map_err(|error| error.to_string()) } diff --git a/src-tauri/src/commands/profiles.rs b/src-tauri/src/commands/profiles.rs index 67c0d85..2b46c6f 100644 --- a/src-tauri/src/commands/profiles.rs +++ b/src-tauri/src/commands/profiles.rs @@ -1,47 +1,134 @@ use super::data_dir; -use crate::{operations::LauncherOperations, profile, remote}; +use crate::{installation_lock::InstallationLock, operations::LauncherOperations, profile, remote}; +use serde::Serialize; use tauri::{AppHandle, State}; +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProfileMetadata { + pub snapshot: String, + pub minecraft_version: String, + pub loader_kind: String, + pub loader_version: String, + pub java_major: u8, +} +impl From<&remote::VerifiedSnapshot> for ProfileMetadata { + fn from(snapshot: &remote::VerifiedSnapshot) -> Self { + let game = &snapshot.manifest.minecraft; + Self { + snapshot: snapshot.digest.clone(), + minecraft_version: game.version.clone(), + loader_kind: game.loader.kind.clone(), + loader_version: game.loader.version.clone(), + java_major: game.java_major, + } + } +} + +#[tauri::command] +pub(crate) async fn profile_metadata(profile_id: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?; + Ok(ProfileMetadata::from(&snapshot)) + }) + .await + .map_err(|e| e.to_string())? +} + #[tauri::command] pub(crate) async fn get_server_status(profile_id: String) -> Result { tauri::async_runtime::spawn_blocking(move || { - remote::fetch_server_status(&profile_id).map_err(|error| error.to_string()) + remote::fetch_server_status(&profile_id).map_err(|e| e.to_string()) }) .await - .map_err(|error| format!("Server-status task failed: {error}"))? + .map_err(|e| e.to_string())? } -/// Loads and validates the published ShaCraft manifest before inspecting a profile. #[tauri::command] pub(crate) async fn inspect_remote_profile( app: AppHandle, + state: State<'_, LauncherOperations>, profile_id: String, ) -> Result { - let data_dir = data_dir(&app)?; + let directory = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&directory, &state)?; tauri::async_runtime::spawn_blocking(move || { - let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?; - profile::inspect(&data_dir.join("profiles").join(&manifest.id), &manifest) - .map_err(|error| error.to_string()) + let _lifecycle = lifecycle; + // Inspection must not report a partially applied journal as ready. + let _lock = InstallationLock::acquire(&directory)?; + let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?; + profile::inspect(&directory.join("profiles").join(&manifest.id), &manifest) + .map_err(|e| e.to_string()) }) .await - .map_err(|error| format!("Profile inspection task failed: {error}"))? + .map_err(|e| e.to_string())? } -/// Downloads missing or changed ShaCraft-managed files from the fixed v2 endpoint. #[tauri::command] pub(crate) async fn sync_remote_profile( app: AppHandle, state: State<'_, LauncherOperations>, profile_id: String, ) -> Result { - let data_dir = data_dir(&app)?; + let directory = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&directory, &state)?; let permit = state.installation.acquire("Installation")?; tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; let _permit = permit; - let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?; - profile::sync(&data_dir.join("profiles").join(&manifest.id), &manifest) - .map_err(|error| error.to_string()) + let _lock = InstallationLock::acquire(&directory)?; + let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?; + profile::sync_snapshot( + &directory.join("profiles").join(&snapshot.manifest.id), + &snapshot, + ) + .map_err(|e| e.to_string()) }) .await - .map_err(|error| format!("Profile synchronization task failed: {error}"))? + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn legacy_mods( + app: AppHandle, + state: State<'_, LauncherOperations>, + profile_id: String, +) -> Result, String> { + let directory = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&directory, &state)?; + tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; + let _lock = InstallationLock::acquire(&directory)?; + let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?; + profile::list_legacy_mods(&directory.join("profiles").join(&manifest.id), &manifest) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub(crate) async fn backup_legacy_mods( + app: AppHandle, + state: State<'_, LauncherOperations>, + profile_id: String, + selections: Vec, +) -> Result { + let directory = data_dir(&app)?; + let lifecycle = crate::update_guard::begin_operation(&directory, &state)?; + let permit = state.installation.acquire("Legacy migration")?; + tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; + let _permit = permit; + let _lock = InstallationLock::acquire(&directory)?; + let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?; + profile::backup_legacy_mods( + &directory.join("profiles").join(&manifest.id), + &manifest, + &selections, + ) + .map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? } diff --git a/src-tauri/src/commands/shacraft.rs b/src-tauri/src/commands/shacraft.rs index fea726d..e1f5e9d 100644 --- a/src-tauri/src/commands/shacraft.rs +++ b/src-tauri/src/commands/shacraft.rs @@ -13,10 +13,12 @@ async fn account_task( work: impl FnOnce(&Path) -> Result + Send + 'static, ) -> Result { let directory = data_dir(app)?; + let lifecycle = crate::update_guard::begin_operation(&directory, operations)?; let permit = operations .shacraft_account .acquire("ShaCraft account operation")?; tauri::async_runtime::spawn_blocking(move || { + let _lifecycle = lifecycle; let _permit = permit; work(&directory).map_err(|error| error.to_string()) }) diff --git a/src-tauri/src/commands/updater.rs b/src-tauri/src/commands/updater.rs new file mode 100644 index 0000000..51edf5a --- /dev/null +++ b/src-tauri/src/commands/updater.rs @@ -0,0 +1,158 @@ +//! No updater command accepts a URL, key, target, version, executable or arguments. +use super::data_dir; +use crate::{ + operations::LauncherOperations, + update_guard, + updater::{self, UpdateStatus, UpdaterState}, +}; +use tauri::{AppHandle, Manager, State}; + +fn pending(app: &AppHandle, state: &UpdaterState) -> Result, String> { + let operations = app.state::(); + if let Err(reason) = operations.ensure_writable() { + return Ok(Some(state.recovery(app, reason))); + } + let directory = data_dir(app)?; + match update_guard::pending_reason(&directory, env!("CARGO_PKG_VERSION")) { + Ok(Some(reason)) | Err(reason) => { + // Recovery discovered after startup is just as permanent for this + // process. External marker deletion cannot resume native writes. + operations.latch_recovery(reason.clone()); + Ok(Some(state.recovery(app, reason))) + } + Ok(None) => Ok(None), + } +} + +fn contain_worker_panic( + work: impl FnOnce() -> Result, + panic_message: &str, +) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)) + .unwrap_or_else(|_| Err(panic_message.to_string())) +} + +fn report_failure(app: &AppHandle, state: &UpdaterState, error: String) -> UpdateStatus { + match pending(app, state) { + Ok(Some(recovery)) => recovery, + Ok(None) => state.fail(app, error), + Err(reason) => { + app.state::() + .latch_recovery(reason.clone()); + state.recovery(app, reason) + } + } +} + +#[tauri::command] +pub(crate) fn updater_status( + app: AppHandle, + state: State<'_, UpdaterState>, +) -> Result { + if state.critical() { + return Ok(state.status()); + } + Ok(pending(&app, &state)?.unwrap_or_else(|| state.status())) +} + +#[tauri::command] +pub(crate) async fn updater_check( + app: AppHandle, + state: State<'_, UpdaterState>, +) -> Result { + let permit = state.acquire()?; + if let Some(status) = pending(&app, &state)? { + return Ok(status); + } + let state = state.inner().clone(); + let worker_app = app.clone(); + let worker_state = state.clone(); + let worker = tauri::async_runtime::spawn_blocking(move || { + let _permit = permit; + // Publish failure from the worker even if its IPC caller disappeared. + contain_worker_panic( + || updater::check(&worker_app, &worker_state), + "Проверка обновления прервалась. Повторите проверку.", + ) + .unwrap_or_else(|error| report_failure(&worker_app, &worker_state, error)) + }); + Ok(worker.await.unwrap_or_else(|_| { + report_failure( + &app, + &state, + "Проверка обновления прервалась. Повторите проверку.".into(), + ) + })) +} + +#[tauri::command] +pub(crate) async fn updater_download_install( + app: AppHandle, + state: State<'_, UpdaterState>, +) -> Result { + let permit = state.acquire()?; + if let Some(status) = pending(&app, &state)? { + return Ok(status); + } + let directory = data_dir(&app)?; + let state = state.inner().clone(); + let worker_app = app.clone(); + let worker_state = state.clone(); + let worker = tauri::async_runtime::spawn_blocking(move || { + let _permit = permit; + contain_worker_panic( + || { + let operations = worker_app.state::(); + updater::download_install(&worker_app, &worker_state, &directory, &operations) + }, + "Установка обновления прервалась; проверьте состояние перед повторной попыткой.", + ) + .unwrap_or_else(|error| report_failure(&worker_app, &worker_state, error)) + }); + Ok(worker.await.unwrap_or_else(|_| { + report_failure( + &app, + &state, + "Установка обновления прервалась; проверьте состояние перед повторной попыткой.".into(), + ) + })) +} + +#[tauri::command] +pub(crate) fn updater_restart( + app: AppHandle, + state: State<'_, UpdaterState>, +) -> Result<(), String> { + let _permit = state.acquire()?; + if !state.ready() { + return Err("Нет завершённого обновления для перезапуска".into()); + } + app.restart() +} + +#[tauri::command] +pub(crate) fn updater_open_release_page() -> Result<(), String> { + updater::open_release_page() +} + +#[cfg(test)] +mod tests { + use super::contain_worker_panic; + use crate::operations::Operation; + + #[test] + fn panicking_worker_releases_owned_permit_and_returns_a_retryable_boundary_error() { + let operation = Operation::default(); + let permit = operation.acquire("test updater").unwrap(); + let result: Result<(), String> = contain_worker_panic( + move || { + let _permit = permit; + panic!("synthetic worker failure"); + }, + "worker interrupted", + ); + assert_eq!(result, Err("worker interrupted".into())); + assert!(operation.acquire("retry").is_ok()); + assert_eq!(contain_worker_panic(|| Ok(42), "unused"), Ok(42)); + } +} diff --git a/src-tauri/src/installation_lock.rs b/src-tauri/src/installation_lock.rs new file mode 100644 index 0000000..4d60e66 --- /dev/null +++ b/src-tauri/src/installation_lock.rs @@ -0,0 +1,326 @@ +//! One writer for the entire shared game tree, across launcher instances. +//! The OS lock is retained by the child watcher. A durable process lease also +//! protects a detached Minecraft after the launcher exits (PID + start time, +//! never PID alone). An interrupted spawn with no recorded child fails closed. +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File, OpenOptions}, + io, + path::{Path, PathBuf}, +}; +use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System}; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +struct ProcessIdentity { + pid: u32, + started: u64, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "state")] +enum Lease { + Starting { launcher: ProcessIdentity }, + Running { game: ProcessIdentity }, +} + +fn process_identity(pid: u32) -> Result, String> { + let me = Pid::from_u32(std::process::id()); + let target = Pid::from_u32(pid); + let mut system = System::new(); + // sysinfo resets each refreshed process's updated flag while removing dead + // entries. Repeating a PID makes the second pass remove that live entry. + let pids = if me == target { + vec![me] + } else { + vec![me, target] + }; + system.refresh_processes_specifics( + ProcessesToUpdate::Some(&pids), + true, + ProcessRefreshKind::nothing().without_tasks(), + ); + // An unsupported/failed process inspection must not permit file mutation. + if system.process(me).is_none() { + return Err("Не удалось проверить запущенные процессы; запись файлов заблокирована".into()); + } + system + .process(target) + .map(|process| { + let started = process.start_time(); + if started == 0 { + return Err("Не удалось определить время запуска игры".into()); + } + Ok(ProcessIdentity { pid, started }) + }) + .transpose() +} + +fn ordinary_path(path: &Path) -> Result<(), String> { + match fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => { + Err("Служебный путь блокировки является ссылкой".into()) + } + Ok(_) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.to_string()), + } +} + +pub(crate) struct InstallationLock { + _file: File, + lease: PathBuf, +} + +impl Drop for InstallationLock { + fn drop(&mut self) { + // A concurrent Unix spawn can retain an inherited copy until exec. + // Release this owner's lock explicitly instead of waiting for every + // duplicate descriptor to close. Never remove the durable game lease. + // If unlock fails, closing the file still leaves the OS fail-closed. + let _ = self._file.unlock(); + } +} + +impl InstallationLock { + pub fn acquire(data_dir: &Path) -> Result { + ordinary_path(data_dir)?; + fs::create_dir_all(data_dir).map_err(|e| e.to_string())?; + let directory = data_dir.join("installation-state"); + ordinary_path(&directory)?; + fs::create_dir_all(&directory).map_err(|e| e.to_string())?; + let path = directory.join("writer.lock"); + ordinary_path(&path)?; + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(&path).map_err(|e| e.to_string())?; + file.try_lock().map_err(|_| "Сборка используется другим экземпляром лаунчера или игрой. Закройте игру и дождитесь завершения операции.".to_string())?; + let guard = Self { + _file: file, + lease: directory.join("game-lease.json"), + }; + ordinary_path(&guard.lease)?; + match fs::read(&guard.lease) { + Ok(bytes) => { + let lease: Lease = serde_json::from_slice(&bytes).map_err(|_| "Повреждена запись запущенной игры; запись файлов остановлена. Закройте Minecraft и восстановите служебную запись по инструкции.".to_string())?; + match lease { + Lease::Starting { .. } => return Err("Предыдущий запуск прервался до регистрации процесса. Запись файлов заблокирована: сначала завершите Minecraft и выполните ручное восстановление game-lease.json по инструкции.".into()), + Lease::Running { game } => { + if process_identity(game.pid)?.as_ref() == Some(&game) { + return Err("Minecraft ещё работает. Перед обновлением или восстановлением закройте игру.".into()); + } + fs::remove_file(&guard.lease).map_err(|e| e.to_string())?; + } + } + } + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(e.to_string()), + } + Ok(guard) + } + + fn store(&self, value: &Lease) -> Result<(), String> { + ordinary_path(&self.lease)?; + crate::storage::write_atomic( + &self.lease, + &serde_json::to_vec(value).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string()) + } + + /// Write ahead of spawn, while holding the OS lock, closing the crash window + /// in which a child could exist with no durable evidence whatsoever. + pub fn starting(&self) -> Result<(), String> { + let launcher = + process_identity(std::process::id())?.ok_or("Launcher process disappeared")?; + self.store(&Lease::Starting { launcher }) + } + + pub fn running(&self, pid: u32) -> Result<(), String> { + let game = process_identity(pid)?.ok_or("Игра завершилась во время запуска")?; + self.store(&Lease::Running { game }) + } + + /// Only the owner, after failed spawn or wait() proving child termination, + /// clears the lease. Drop intentionally does not clear it. + pub fn finished(&self) -> Result<(), String> { + match fs::remove_file(&self.lease) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + fn dir() -> PathBuf { + let p = std::env::temp_dir().join(format!( + "shacraft-lock-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&p).unwrap(); + p + } + #[test] + fn separate_file_descriptions_exclude_writers() { + let p = dir(); + let a = InstallationLock::acquire(&p).unwrap(); + assert!(InstallationLock::acquire(&p).is_err()); + drop(a); + drop( + InstallationLock::acquire(&p) + .unwrap_or_else(|error| panic!("expected released lock: {error}")), + ); + fs::remove_dir_all(p).unwrap(); + } + #[test] + fn live_game_lease_survives_dropping_launcher_lock() { + let p = dir(); + let a = InstallationLock::acquire(&p).unwrap(); + a.starting().unwrap(); + a.running(std::process::id()).unwrap(); + drop(a); + let error = InstallationLock::acquire(&p).unwrap_err_string(); + assert!(error.contains("Minecraft"), "unexpected refusal: {error}"); + fs::remove_dir_all(p).unwrap(); + } + #[test] + fn reused_pid_with_different_start_does_not_block_forever() { + let p = dir(); + let a = InstallationLock::acquire(&p).unwrap(); + a.store(&Lease::Running { + game: ProcessIdentity { + pid: std::process::id(), + started: 1, + }, + }) + .unwrap(); + drop(a); + drop( + InstallationLock::acquire(&p) + .unwrap_or_else(|error| panic!("expected released lock: {error}")), + ); + fs::remove_dir_all(p).unwrap(); + } + #[test] + fn inherited_file_description_does_not_extend_owner_guard_lifetime() { + let p = dir(); + let guard = InstallationLock::acquire(&p).unwrap(); + guard + .store(&Lease::Running { + game: ProcessIdentity { + pid: std::process::id(), + started: 1, + }, + }) + .unwrap(); + // A concurrent Unix spawn inherits this same open file description + // until exec closes its CLOEXEC copy. try_clone deterministically keeps + // that description alive without relying on fork timing or sleeps. + let inherited = guard._file.try_clone().unwrap(); + assert!(InstallationLock::acquire(&p).is_err()); + drop(guard); + let reacquired = InstallationLock::acquire(&p) + .unwrap_or_else(|error| panic!("owner dropped but lock remained: {error}")); + assert!(!p.join("installation-state/game-lease.json").exists()); + drop(inherited); + drop(reacquired); + fs::remove_dir_all(p).unwrap(); + } + + #[test] + fn releasing_owner_lock_keeps_live_game_lease_with_inherited_description() { + let p = dir(); + let guard = InstallationLock::acquire(&p).unwrap(); + guard.running(std::process::id()).unwrap(); + let inherited = guard._file.try_clone().unwrap(); + drop(guard); + let error = InstallationLock::acquire(&p).unwrap_err_string(); + assert!(error.contains("Minecraft"), "unexpected refusal: {error}"); + assert!(p.join("installation-state/game-lease.json").exists()); + drop(inherited); + fs::remove_dir_all(p).unwrap(); + } + + #[test] + fn interrupted_spawn_fails_closed() { + let p = dir(); + let a = InstallationLock::acquire(&p).unwrap(); + a.starting().unwrap(); + drop(a); + assert!(InstallationLock::acquire(&p).is_err()); + fs::remove_dir_all(p).unwrap(); + } + #[test] + fn current_process_identity_is_detected_without_duplicate_pid_removal() { + let pid = std::process::id(); + let identity = process_identity(pid).unwrap().unwrap(); + assert_eq!(identity.pid, pid); + assert!(identity.started > 0); + } + + #[test] + fn real_child_lease_blocks_until_child_exits_after_launcher_guard_drops() { + const CHILD_MODE: &str = "SHACRAFT_LEASE_TEST_CHILD"; + if std::env::var_os(CHILD_MODE).is_some() { + use std::io::Read; + let mut bytes = Vec::new(); + std::io::stdin().read_to_end(&mut bytes).unwrap(); + return; + } + // Launch this one test in child mode; stdin keeps it alive without a + // platform shell, installed external program or arbitrary sleep. + struct TestChild(std::process::Child); + impl Drop for TestChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let mut child = TestChild(std::process::Command::new(std::env::current_exe().unwrap()) + .arg("--exact") + .arg("installation_lock::tests::real_child_lease_blocks_until_child_exits_after_launcher_guard_drops") + .env(CHILD_MODE, "1") + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn().unwrap()); + let directory = dir(); + let guard = InstallationLock::acquire(&directory).unwrap(); + guard.starting().unwrap(); + guard.running(child.0.id()).unwrap(); + drop(guard); + assert!(InstallationLock::acquire(&directory) + .unwrap_err_string() + .contains("Minecraft")); + child.0.kill().unwrap(); + child.0.wait().unwrap(); + let guard = InstallationLock::acquire(&directory).unwrap(); + assert!(!directory + .join("installation-state/game-lease.json") + .exists()); + drop(guard); + fs::remove_dir_all(directory).unwrap(); + } + + trait ErrorText { + fn unwrap_err_string(self) -> String; + } + impl ErrorText for Result { + fn unwrap_err_string(self) -> String { + match self { + Err(e) => e, + Ok(_) => panic!("expected lock refusal"), + } + } + } +} diff --git a/src-tauri/src/inventory.rs b/src-tauri/src/inventory.rs new file mode 100644 index 0000000..022231a --- /dev/null +++ b/src-tauri/src/inventory.rs @@ -0,0 +1,602 @@ +//! Launcher-owned profile state lives next to, never inside, the payload tree. +//! Callers hold the installation lock. Local records are not remote manifests: +//! they describe completed launcher writes, and never adopt an existing file. +use crate::{download, manifest::is_portable_component, storage}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + fs, + io::{self, Read}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; + +const MAX_STATE_BYTES: u64 = 8 * 1024 * 1024; +static NEXT_TRANSACTION: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub(crate) struct Fingerprint { + pub size: u64, + pub sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub(crate) struct OwnedFile { + pub fingerprint: Fingerprint, + pub snapshot: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct Inventory { + pub version: u32, + pub files: BTreeMap, +} +impl Default for Inventory { + fn default() -> Self { + Self { + version: 1, + files: BTreeMap::new(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Change { + pub path: String, + pub before: Option, + pub after: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Journal { + version: u32, + pub transaction: String, + pub changes: Vec, + pub next: Inventory, +} + +pub(crate) struct Store { + pub root: PathBuf, + profile: PathBuf, +} + +pub(crate) fn invalid(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +pub(crate) fn safe_relative(relative: &str) -> bool { + !relative.is_empty() && relative.split('/').all(is_portable_component) +} + +// Personal game data is never eligible for automated profile management. +pub(crate) fn protected(relative: &str) -> bool { + matches!( + relative + .split('/') + .next() + .unwrap_or("") + .to_ascii_lowercase() + .as_str(), + "saves" | "worlds" | "screenshots" | "logs" | "crash-reports" + ) +} + +pub(crate) fn checked_path(root: &Path, relative: &str) -> io::Result { + if !safe_relative(relative) { + return Err(invalid("Unsafe stored profile path")); + } + reject_links(root)?; + let mut path = root.to_path_buf(); + for component in relative.split('/') { + path.push(component); + match fs::symlink_metadata(&path) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(invalid("Profile path contains a symbolic link")) + } + Ok(_) => {} + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + } + Ok(path) +} + +fn reject_links(path: &Path) -> io::Result<()> { + // The caller supplies the trusted profile/state root. Check that root and + // its immediate parent; checked_path walks every descendant separately. + // System ancestors may legitimately be links (e.g. /var on macOS). + for ancestor in path.ancestors().take(2) { + match fs::symlink_metadata(ancestor) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(invalid( + "Launcher state/profile path contains a symbolic link", + )) + } + Ok(_) => {} + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + } + Ok(()) +} + +pub(crate) fn fingerprint(path: &Path) -> io::Result> { + let metadata = match fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e), + }; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(invalid("Expected a regular profile file")); + } + Ok(Some(Fingerprint { + size: metadata.len(), + sha256: download::file_hashes(path)?.1, + })) +} + +fn valid_fingerprint(value: &Fingerprint) -> bool { + value.sha256.len() == 64 && value.sha256.bytes().all(|b| b.is_ascii_hexdigit()) +} +fn validate_inventory(inventory: &Inventory) -> io::Result<()> { + if inventory.version != 1 + || inventory.files.iter().any(|(path, record)| { + !safe_relative(path) + || protected(path) + || !valid_fingerprint(&record.fingerprint) + || record.snapshot.len() != 64 + || !record.snapshot.bytes().all(|b| b.is_ascii_hexdigit()) + }) + { + return Err(invalid( + "Invalid launcher ownership inventory; existing files were preserved", + )); + } + Ok(()) +} + +fn read_json Deserialize<'de>>(path: &Path) -> io::Result> { + reject_links(path)?; + let file = match fs::File::open(path) { + Ok(file) => file, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e), + }; + let mut bytes = Vec::new(); + file.take(MAX_STATE_BYTES + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_STATE_BYTES { + return Err(invalid("Launcher state is too large")); + } + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| invalid("Invalid launcher state; existing files were preserved")) +} + +impl Store { + pub fn open(profile: &Path) -> io::Result { + reject_links(profile)?; + let name = profile + .file_name() + .and_then(|s| s.to_str()) + .filter(|s| is_portable_component(s)) + .ok_or_else(|| invalid("Invalid profile directory"))?; + let parent = profile + .parent() + .ok_or_else(|| invalid("Profile needs a parent directory"))?; + let root = parent.join(format!(".{name}.shacraft-state")); + reject_links(&root)?; + Ok(Self { + root, + profile: profile.to_path_buf(), + }) + } + pub fn load(&self) -> io::Result { + let inventory = read_json(&self.root.join("inventory.json"))?.unwrap_or_default(); + validate_inventory(&inventory)?; + Ok(inventory) + } + pub fn pending(&self) -> io::Result { + Ok(self.read_journal()?.is_some()) + } + fn read_journal(&self) -> io::Result> { + let journal: Option = read_json(&self.root.join("pending.json"))?; + if let Some(journal) = &journal { + validate_inventory(&journal.next)?; + let mut paths = std::collections::HashSet::new(); + if journal.version != 1 + || !is_portable_component(&journal.transaction) + || !journal.transaction.starts_with("tx-") + || journal.changes.iter().any(|change| { + !safe_relative(&change.path) + || protected(&change.path) + || !paths.insert(change.path.to_lowercase()) + || change + .before + .as_ref() + .is_some_and(|f| !valid_fingerprint(f)) + || change.after.as_ref().is_some_and(|f| !valid_fingerprint(f)) + }) + { + return Err(invalid( + "Invalid pending update; existing files were preserved", + )); + } + } + Ok(journal) + } + pub fn transaction(&self) -> io::Result { + reject_links(&self.root)?; + fs::create_dir_all(&self.root)?; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let name = format!( + "tx-{timestamp}-{}-{}", + std::process::id(), + NEXT_TRANSACTION.fetch_add(1, Ordering::Relaxed) + ); + fs::create_dir(self.root.join(&name))?; + fs::create_dir(self.root.join(&name).join("staged"))?; + fs::create_dir(self.root.join(&name).join("backup"))?; + Ok(name) + } + pub fn stage(&self, transaction: &str, index: usize) -> io::Result { + checked_path(&self.root, &format!("{transaction}/staged/{index}")) + } + pub fn prepare( + &self, + transaction: String, + changes: Vec, + next: Inventory, + ) -> io::Result<()> { + if self.pending()? { + return Err(invalid("A previous profile update needs recovery")); + } + validate_inventory(&next)?; + let journal = Journal { + version: 1, + transaction, + changes, + next, + }; + let bytes = serde_json::to_vec(&journal).map_err(|e| invalid(e.to_string()))?; + if bytes.len() as u64 > MAX_STATE_BYTES { + return Err(invalid("Profile update journal is too large")); + } + let transaction_root = checked_path(&self.root, &journal.transaction)?; + storage::write_atomic(&transaction_root.join("receipt.json"), &bytes)?; + sync_directory(&transaction_root.join("staged"))?; + sync_directory(&transaction_root)?; + storage::write_atomic(&self.root.join("pending.json"), &bytes)?; + sync_directory(&self.root) + } + /// Roll forward only when every affected path still matches its before or + /// after image. Staged bytes are rehashed; unexpected local changes stop + /// recovery without overwriting them. Backups remain available to the user. + pub fn recover(&self) -> io::Result<()> { + let Some(journal) = self.read_journal()? else { + return Ok(()); + }; + // Check every transition first, before moving any remaining file. + for (index, change) in journal.changes.iter().enumerate() { + self.check_change(&journal.transaction, index, change)?; + } + for (index, change) in journal.changes.iter().enumerate() { + self.apply_change(&journal.transaction, index, change)?; + } + let bytes = serde_json::to_vec(&journal.next).map_err(|e| invalid(e.to_string()))?; + storage::write_atomic(&self.root.join("inventory.json"), &bytes)?; + sync_directory(&self.root)?; + fs::remove_file(self.root.join("pending.json"))?; + sync_directory(&self.root) + } + fn check_change(&self, transaction: &str, index: usize, change: &Change) -> io::Result<()> { + let target = checked_path(&self.profile, &change.path)?; + let actual = fingerprint(&target)?; + let backup = checked_path(&self.root, &format!("{transaction}/backup/{index}"))?; + let saved = fingerprint(&backup)?; + if actual == change.after && (change.before.is_none() || saved == change.before) { + // A consumed stage proves that the launcher completed the rename. + // If it is still present, identical bytes may have been created by + // the user during download; do not silently adopt that file. + if change.after.is_some() && fingerprint(&self.stage(transaction, index)?)?.is_some() { + return Err(invalid(format!( + "Update conflict: {} appeared during staging; file preserved", + change.path + ))); + } + return Ok(()); + } + if actual != change.before && !(actual.is_none() && saved == change.before) { + return Err(invalid(format!( + "Update conflict: {} changed; file preserved", + change.path + ))); + } + if actual.is_some() && saved.is_some() { + return Err(invalid(format!( + "Update conflict: {} and its backup both exist", + change.path + ))); + } + if let Some(after) = &change.after { + if fingerprint(&self.stage(transaction, index)?)?.as_ref() != Some(after) { + return Err(invalid(format!( + "Staged file is missing or corrupt: {}; retry needs recovery", + change.path + ))); + } + } + Ok(()) + } + fn apply_change(&self, transaction: &str, index: usize, change: &Change) -> io::Result<()> { + self.check_change(transaction, index, change)?; + let target = checked_path(&self.profile, &change.path)?; + let actual = fingerprint(&target)?; + let backup = checked_path(&self.root, &format!("{transaction}/backup/{index}"))?; + if actual == change.after { + return Ok(()); + } + if actual.is_some() { + fs::rename(&target, &backup)?; + sync_directory(target.parent().unwrap())?; + sync_directory(backup.parent().unwrap())?; + } + if change.after.is_some() { + fs::create_dir_all(target.parent().unwrap())?; + fs::rename(self.stage(transaction, index)?, &target)?; + sync_directory(target.parent().unwrap())?; + } + Ok(()) + } +} + +fn sync_directory(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + fs::File::open(path)?.sync_all()?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use sha2::{Digest, Sha256}; + struct Fixture { + base: PathBuf, + profile: PathBuf, + store: Store, + } + impl Fixture { + fn new() -> Self { + let base = std::env::temp_dir().join(format!( + "shacraft-journal-{}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT_TRANSACTION.fetch_add(1, Ordering::Relaxed) + )); + let profile = base.join("profiles/aeronautics"); + fs::create_dir_all(profile.join("mods")).unwrap(); + let store = Store::open(&profile).unwrap(); + Self { + base, + profile, + store, + } + } + fn replacement(&self) -> (String, Change) { + fs::write(self.profile.join("mods/current.jar"), b"old").unwrap(); + let transaction = self.store.transaction().unwrap(); + fs::write(self.store.stage(&transaction, 0).unwrap(), b"new").unwrap(); + let change = Change { + path: "mods/current.jar".into(), + before: Some(fp(b"old")), + after: Some(fp(b"new")), + }; + let mut next = Inventory::default(); + next.files.insert( + change.path.clone(), + OwnedFile { + fingerprint: fp(b"new"), + snapshot: "a".repeat(64), + }, + ); + self.store + .prepare(transaction.clone(), vec![change.clone()], next) + .unwrap(); + (transaction, change) + } + } + impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.base).unwrap(); + } + } + fn fp(bytes: &[u8]) -> Fingerprint { + Fingerprint { + size: bytes.len() as u64, + sha256: format!("{:x}", Sha256::digest(bytes)), + } + } + + #[test] + fn crash_after_backing_up_old_file_finishes_replacement_and_ownership() { + let f = Fixture::new(); + let (transaction, _) = f.replacement(); + let backup = f.store.root.join(&transaction).join("backup/0"); + fs::rename(f.profile.join("mods/current.jar"), &backup).unwrap(); + assert!(f.store.pending().unwrap()); + f.store.recover().unwrap(); + assert_eq!( + fs::read(f.profile.join("mods/current.jar")).unwrap(), + b"new" + ); + assert_eq!(fs::read(backup).unwrap(), b"old"); + assert_eq!( + f.store.load().unwrap().files["mods/current.jar"].fingerprint, + fp(b"new") + ); + assert!(!f.store.pending().unwrap()); + f.store.recover().unwrap(); // idempotent repeated recovery + } + #[test] + fn crash_after_payload_commit_before_inventory_is_recoverable() { + let f = Fixture::new(); + let (transaction, change) = f.replacement(); + f.store.apply_change(&transaction, 0, &change).unwrap(); + assert!(f.store.load().unwrap().files.is_empty()); + assert!(f.store.pending().unwrap()); + f.store.recover().unwrap(); + assert_eq!(f.store.load().unwrap().files.len(), 1); + assert_eq!( + fs::read(f.profile.join("mods/current.jar")).unwrap(), + b"new" + ); + } + #[test] + fn corrupt_staging_or_locally_changed_target_preserves_payload_and_journal() { + let f = Fixture::new(); + let (transaction, _) = f.replacement(); + fs::write(f.store.stage(&transaction, 0).unwrap(), b"corrupt").unwrap(); + assert!(f.store.recover().is_err()); + assert_eq!( + fs::read(f.profile.join("mods/current.jar")).unwrap(), + b"old" + ); + assert!(f.store.pending().unwrap()); + fs::write(f.store.stage(&transaction, 0).unwrap(), b"new").unwrap(); + fs::write(f.profile.join("mods/current.jar"), b"user").unwrap(); + assert!(f.store.recover().is_err()); + assert_eq!( + fs::read(f.profile.join("mods/current.jar")).unwrap(), + b"user" + ); + assert!(f.store.pending().unwrap()); + } + #[test] + fn validates_all_transitions_before_resuming_any_remaining_move() { + let f = Fixture::new(); + fs::write(f.profile.join("mods/first.jar"), b"first").unwrap(); + fs::write(f.profile.join("mods/second.jar"), b"second").unwrap(); + let transaction = f.store.transaction().unwrap(); + let changes = vec![ + Change { + path: "mods/first.jar".into(), + before: Some(fp(b"first")), + after: None, + }, + Change { + path: "mods/second.jar".into(), + before: Some(fp(b"second")), + after: None, + }, + ]; + f.store + .prepare(transaction, changes, Inventory::default()) + .unwrap(); + fs::write(f.profile.join("mods/second.jar"), b"edits").unwrap(); + assert!(f.store.recover().is_err()); + assert_eq!( + fs::read(f.profile.join("mods/first.jar")).unwrap(), + b"first" + ); + assert_eq!( + fs::read(f.profile.join("mods/second.jar")).unwrap(), + b"edits" + ); + } + #[test] + fn pending_transaction_prevents_ready_even_if_current_manifest_files_match() { + let f = Fixture::new(); + let (transaction, change) = f.replacement(); + f.store.apply_change(&transaction, 0, &change).unwrap(); + let manifest = crate::manifest::validate_json(&format!(r#"{{"schemaVersion":1,"id":"aeronautics","displayName":"Test","minecraft":{{"version":"1.21.1","loader":{{"kind":"neoforge","version":"21.1.248"}},"javaMajor":21}},"files":[{{"path":"mods/current.jar","url":"https://cdn.shacraft.ru/current.jar","size":3,"sha256":"{}","policy":"managed"}}]}}"#, fp(b"new").sha256)).unwrap(); + let inspection = crate::profile::inspect(&f.profile, &manifest).unwrap(); + assert!(inspection.pending_update); + assert!(!inspection.up_to_date); + f.store.recover().unwrap(); + assert!( + crate::profile::inspect(&f.profile, &manifest) + .unwrap() + .up_to_date + ); + } + #[test] + fn matching_file_created_during_staging_is_not_adopted() { + let f = Fixture::new(); + let transaction = f.store.transaction().unwrap(); + fs::write(f.store.stage(&transaction, 0).unwrap(), b"new").unwrap(); + let mut next = Inventory::default(); + next.files.insert( + "mods/new.jar".into(), + OwnedFile { + fingerprint: fp(b"new"), + snapshot: "a".repeat(64), + }, + ); + f.store + .prepare( + transaction, + vec![Change { + path: "mods/new.jar".into(), + before: None, + after: Some(fp(b"new")), + }], + next, + ) + .unwrap(); + fs::write(f.profile.join("mods/new.jar"), b"new").unwrap(); + assert!(f.store.recover().is_err()); + assert!(f.store.load().unwrap().files.is_empty()); + assert_eq!(fs::read(f.profile.join("mods/new.jar")).unwrap(), b"new"); + assert!(f.store.pending().unwrap()); + } + + #[test] + fn corrupt_or_unsafe_inventory_fails_closed_without_adoption() { + let f = Fixture::new(); + f.store.transaction().unwrap(); + fs::write(f.store.root.join("inventory.json"), b"broken JSON").unwrap(); + assert!(f.store.load().is_err()); + let mut inventory = Inventory::default(); + inventory.files.insert( + "../outside.jar".into(), + OwnedFile { + fingerprint: fp(b"outside"), + snapshot: "a".repeat(64), + }, + ); + fs::write( + f.store.root.join("inventory.json"), + serde_json::to_vec(&inventory).unwrap(), + ) + .unwrap(); + assert!(f.store.load().is_err()); + } + #[cfg(unix)] + #[test] + fn linked_state_and_payload_are_refused() { + use std::os::unix::fs::symlink; + let f = Fixture::new(); + let outside = f.base.join("outside"); + fs::create_dir(&outside).unwrap(); + symlink(&outside, &f.store.root).unwrap(); + assert!(Store::open(&f.profile).is_err()); + fs::remove_file(&f.store.root).unwrap(); + symlink(&outside, f.profile.join("mods/linked.jar")).unwrap(); + assert!(checked_path(&f.profile, "mods/linked.jar").is_err()); + } +} diff --git a/src-tauri/src/java.rs b/src-tauri/src/java.rs index e96e086..6015d24 100644 --- a/src-tauri/src/java.rs +++ b/src-tauri/src/java.rs @@ -60,18 +60,28 @@ pub fn ensure_java( required_major: u8, on_progress: &ProgressCallback, ) -> Result { - if let Some(installation) = detect() { - if installation.major == required_major { - on_progress(1, 1); - return Ok(installation); - } + if let Some(installation) = find_matching(candidates(), required_major, check_candidate) { + on_progress(1, 1); + return Ok(installation); } let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress) .map_err(EnsureJavaError::Provisioning)?; check_candidate(executable.clone()) + .filter(|installation| installation.major == required_major) .ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable)) } +fn find_matching( + candidates: impl IntoIterator, + required_major: u8, + mut inspect: impl FnMut(PathBuf) -> Option, +) -> Option { + candidates + .into_iter() + .filter_map(&mut inspect) + .find(|installation| installation.major == required_major) +} + fn candidates() -> Vec { let executable = if cfg!(target_os = "windows") { "java.exe" @@ -127,7 +137,48 @@ fn parse_major(version: &str) -> Option { #[cfg(test)] mod tests { - use super::{parse_major, parse_version}; + use super::*; + + #[test] + fn matching_path_runtime_is_not_hidden_by_wrong_java_home() { + let candidates = ["JAVA_HOME", "PATH"].map(PathBuf::from); + let found = find_matching(candidates, 21, |path| { + let major = if path == Path::new("JAVA_HOME") { + 17 + } else { + 21 + }; + Some(JavaInstallation { + executable: path.display().to_string(), + major, + version: major.to_string(), + }) + }) + .unwrap(); + assert_eq!(found.executable, "PATH"); + } + + #[test] + fn matching_home_remains_preferred_and_unusable_candidates_are_skipped() { + let found = find_matching(["broken", "home", "path"].map(PathBuf::from), 21, |path| { + assert_ne!(path, Path::new("path"), "must stop at matching JAVA_HOME"); + (path != Path::new("broken")).then(|| JavaInstallation { + executable: path.display().to_string(), + major: 21, + version: "21".into(), + }) + }) + .unwrap(); + assert_eq!(found.executable, "home"); + assert!(find_matching([PathBuf::from("newer")], 21, |path| Some( + JavaInstallation { + executable: path.display().to_string(), + major: 25, + version: "25".into() + } + )) + .is_none()); + } #[test] fn parses_modern_java_version() { diff --git a/src-tauri/src/launch.rs b/src-tauri/src/launch.rs index 71ae513..070f9b6 100644 --- a/src-tauri/src/launch.rs +++ b/src-tauri/src/launch.rs @@ -47,6 +47,8 @@ pub struct LaunchRequest<'a> { pub identity: &'a PlayerIdentity, pub memory_mb: u16, pub log_path: &'a Path, + /// Short-lived onboarding grant; never persisted or placed in command-line arguments. + pub onboarding_token: Option<&'a str>, } fn classpath_separator() -> &'static str { @@ -244,6 +246,12 @@ pub fn launch(request: &LaunchRequest) -> Result { command.arg(substitute(&argument, &vars)); } command.current_dir(request.profile_dir); + // Do not inherit a stale grant from the launcher process environment. + command.env_remove("SHACRAFT_ONBOARDING_TOKEN"); + if let Some(token) = request.onboarding_token { + command.env("SHACRAFT_ONBOARDING_TOKEN", token); + } + command.stdin(Stdio::null()); let log_file = fs::File::create(request.log_path)?; command.stdout(Stdio::from(log_file.try_clone()?)); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 325ad9f..05e638e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,9 @@ mod download; +mod update_guard; +mod updater; +use tauri::Manager; +mod installation_lock; +mod inventory; mod java; mod launch; mod manifest; @@ -20,7 +25,42 @@ mod operations; pub fn run() { tauri::Builder::default() .manage(operations::LauncherOperations::default()) + .manage(updater::UpdaterState::default()) + .plugin( + tauri_plugin_updater::Builder::new() + .pubkey(updater::configured_key().unwrap_or("")) + .build(), + ) + .setup(|app| { + let directory = app.path().app_data_dir()?; + let instance = + update_guard::InstanceGuard::acquire(&directory, env!("CARGO_PKG_VERSION")) + .map_err(std::io::Error::other)?; + if let Some(reason) = instance.recovery_reason() { + app.state::() + .latch_recovery(reason); + } + app.manage(instance); + Ok(()) + }) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + if window + .app_handle() + .state::() + .lifecycle + .is_updating() + { + api.prevent_close(); + } + } + }) .invoke_handler(tauri::generate_handler![ + commands::updater::updater_status, + commands::updater::updater_check, + commands::updater::updater_download_install, + commands::updater::updater_restart, + commands::updater::updater_open_release_page, commands::host::native_host, commands::host::detect_java, commands::host::microsoft_login_available, @@ -28,6 +68,9 @@ pub fn run() { commands::profiles::inspect_remote_profile, commands::profiles::sync_remote_profile, commands::profiles::get_server_status, + commands::profiles::profile_metadata, + commands::profiles::legacy_mods, + commands::profiles::backup_legacy_mods, commands::preferences::load_settings, commands::preferences::save_settings, commands::shacraft::shacraft_authenticate, @@ -39,7 +82,8 @@ pub fn run() { commands::account::get_account, commands::account::logout, commands::game::ensure_game_installed, - commands::game::launch_game + commands::game::launch_game, + commands::game::launch_onboarding ]) .run(tauri::generate_context!()) .expect("error while running ShaCraft Launcher"); diff --git a/src-tauri/src/neoforge.rs b/src-tauri/src/neoforge.rs index b33f61e..25b45c5 100644 --- a/src-tauri/src/neoforge.rs +++ b/src-tauri/src/neoforge.rs @@ -25,6 +25,9 @@ //! arguments already present on the merged profile) and is intentionally //! never added to our own classpath. +#[path = "neoforge_repair.rs"] +mod repair; + use crate::download::{self, Checksum, DownloadError, ProgressCallback}; use crate::mojang::VersionJson; use reqwest::blocking::Client; @@ -56,6 +59,7 @@ pub enum NeoForgeError { Download(DownloadError), Io(io::Error), InvalidJson(serde_json::Error), + InvalidInstallation(String), InstallerFailed { exit_code: Option, output_tail: String, @@ -76,6 +80,9 @@ impl fmt::Display for NeoForgeError { Self::Download(error) => write!(formatter, "{error}"), Self::Io(error) => write!(formatter, "I/O error: {error}"), Self::InvalidJson(error) => write!(formatter, "invalid NeoForge version JSON: {error}"), + Self::InvalidInstallation(message) => { + write!(formatter, "invalid NeoForge installation: {message}") + } Self::InstallerFailed { exit_code, output_tail, @@ -166,25 +173,8 @@ pub fn installed_version_json_path(game_dir: &Path, loader_version: &str) -> Pat .join(format!("neoforge-{loader_version}.json")) } -fn patched_client_path(game_dir: &Path, loader_version: &str) -> PathBuf { - game_dir - .join("libraries/net/neoforged/neoforge") - .join(loader_version) - .join(format!("neoforge-{loader_version}-client.jar")) -} - -fn is_nonempty_file(path: &Path) -> bool { - path.metadata() - .is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0) -} - -fn installation_complete(game_dir: &Path, loader_version: &str) -> bool { - is_nonempty_file(&installed_version_json_path(game_dir, loader_version)) - && is_nonempty_file(&patched_client_path(game_dir, loader_version)) -} - /// The installer jar bundles its own `install_profile.json`, which lists -/// exactly which libraries it will download and which processors it will +/// which libraries it may download and which processors it may /// run to patch the client — the same manifest the installer itself reads. /// Reading it upfront gives a real, version-agnostic total for progress /// reporting instead of a guessed constant. @@ -316,66 +306,51 @@ fn run_installer_with_progress( Ok((status.code(), tail)) } -/// Ensures NeoForge `loader_version` is installed into the shared -/// `game_dir` (vanilla libraries/version must already be there so the -/// installer can reuse them). No-op if already installed. Runs the -/// installer headlessly with `java_executable`; its own network calls go -/// straight to `maven.neoforged.net`/Mojang, outside our control, which is -/// an accepted trust delegation to NeoForge's official tooling once the -/// installer binary itself is SHA-256 verified. `on_progress` reports real -/// progress (installer-confirmed library downloads plus patch-processor -/// steps, read from the installer's own `install_profile.json`) while it -/// runs; it fires once with `(1, 1)` when already installed. +/// Verifies a generated installation against its provenance receipt. Legacy +/// installations and corrupt outputs are rebuilt by the verified official +/// installer in an empty staging directory. The caller must ensure vanilla's +/// client JAR first; the staged copy is checked against `vanilla` again before +/// any processor runs. No existing generated artifacts are adopted as trusted. pub fn ensure_client_installed( client: &Client, java_executable: &Path, game_dir: &Path, cache_dir: &Path, loader_version: &str, + vanilla: &VersionJson, on_progress: &ProgressCallback, ) -> Result { - let version_json_path = installed_version_json_path(game_dir, loader_version); - if !installation_complete(game_dir, loader_version) { - ensure_launcher_profiles_stub(game_dir)?; - let installer_path = ensure_installer(client, cache_dir, loader_version)?; - - // A leftover version JSON makes some installer versions treat the - // profile as already installed even when the patched client was - // deleted or quarantined. Remove only that generated marker so the - // official installer is forced to rebuild the incomplete profile. - match fs::remove_file(&version_json_path) { - Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(NeoForgeError::Io(error)), - } - - let (total_libraries, total_processors) = - read_install_profile_counts(&installer_path).unwrap_or((0, 0)); - let total = (total_libraries + total_processors).max(1); - on_progress(0, total); - - let (exit_code, tail) = run_installer_with_progress( - java_executable, - &installer_path, - game_dir, - cache_dir, - total_libraries, - total, - on_progress, - )?; - if !installation_complete(game_dir, loader_version) { - return Err(NeoForgeError::InstallerFailed { - exit_code, - output_tail: tail, - }); - } - on_progress(total, total); - } else { + let installer_path = ensure_installer(client, cache_dir, loader_version)?; + let mut rebuilt = false; + let version = repair::ensure( + &installer_path, + game_dir, + cache_dir, + loader_version, + vanilla, + |stage| { + rebuilt = true; + let (total_libraries, total_processors) = + read_install_profile_counts(&installer_path).unwrap_or((0, 0)); + let total = (total_libraries + total_processors).max(1); + on_progress(0, total); + run_installer_with_progress( + java_executable, + &installer_path, + stage, + cache_dir, + total_libraries, + total, + on_progress, + )?; + on_progress(total, total); + Ok(()) + }, + )?; + if !rebuilt { on_progress(1, 1); } - - let bytes = fs::read(&version_json_path)?; - serde_json::from_slice(&bytes).map_err(NeoForgeError::InvalidJson) + Ok(version) } #[cfg(test)] @@ -412,25 +387,6 @@ mod tests { fs::remove_dir_all(&dir).unwrap(); } - #[test] - fn incomplete_install_is_not_accepted() { - let dir = std::env::temp_dir().join(format!( - "shacraft-neoforge-completeness-test-{}", - std::process::id() - )); - let version = "21.1.248"; - let json = installed_version_json_path(&dir, version); - fs::create_dir_all(json.parent().unwrap()).unwrap(); - fs::write(&json, b"{}").unwrap(); - assert!(!installation_complete(&dir, version)); - - let client = patched_client_path(&dir, version); - fs::create_dir_all(client.parent().unwrap()).unwrap(); - fs::write(&client, b"patched").unwrap(); - assert!(installation_complete(&dir, version)); - fs::remove_dir_all(dir).unwrap(); - } - #[test] fn observe_installer_line_counts_downloads_and_processor_headers() { let downloads_done = AtomicU64::new(0); @@ -492,8 +448,7 @@ mod tests { /// Full live pipeline: provisions a real Java 21 (runtime.rs) if none /// is already usable, then runs the real NeoForge 21.1.248 installer - /// into an empty game dir (it fetches and patches vanilla 1.21.1 - /// itself — confirmed manually, no pre-seeding needed) and checks the + /// into a staging game dir with a verified vanilla 1.21.1 input and checks the /// installed profile merges into a launch-shaped spec together with a /// separately-fetched vanilla version JSON (mojang.rs), exactly as /// `lib.rs`'s `ensure_game_installed` command will do it. Not run by @@ -518,8 +473,14 @@ mod tests { let java_install = java::ensure_java(&client, &root.join("runtime"), 21, &no_progress).unwrap(); - // The installer fetches and patches vanilla itself; we don't - // pre-download it. It only needs a Java runtime and an empty dir. + // Verify vanilla before the installer is allowed to use it. + mojang::ensure_client_jar( + &client, + &game_dir, + &vanilla.id, + &vanilla.downloads.as_ref().unwrap().client, + ) + .unwrap(); let progress_calls: Arc>> = Arc::new(Mutex::new(Vec::new())); let progress: ProgressCallback = { let progress_calls = Arc::clone(&progress_calls); @@ -531,6 +492,7 @@ mod tests { &game_dir, &cache_dir, "21.1.248", + &vanilla, &progress, ) .unwrap(); @@ -577,6 +539,7 @@ mod tests { &game_dir, &cache_dir, "21.1.248", + &vanilla, &no_progress, ) .unwrap(); diff --git a/src-tauri/src/neoforge_repair.rs b/src-tauri/src/neoforge_repair.rs new file mode 100644 index 0000000..d393c15 --- /dev/null +++ b/src-tauri/src/neoforge_repair.rs @@ -0,0 +1,761 @@ +//! Local provenance for outputs created by the verified official installer. +//! No receipt is ever bootstrapped by hashing an unknown legacy installation. +//! The receipt is a final commit marker, not a vendor signature for output jars. +use super::{ensure_launcher_profiles_stub, installed_version_json_path, NeoForgeError}; +use crate::{download, manifest::is_portable_component, mojang::VersionJson, storage}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, io, + io::Read, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +const MAX_METADATA: u64 = 4 * 1024 * 1024; +static NEXT_STAGE: AtomicU64 = AtomicU64::new(0); + +fn invalid(message: impl Into) -> NeoForgeError { + NeoForgeError::InvalidInstallation(message.into()) +} + +/// Refuse links in every existing ancestor, including launcher root ancestors. +/// Same-user concurrent path substitution remains outside the OS trust model. +fn safe_path(root: &Path, relative: &str) -> Result { + if relative.is_empty() || !relative.split('/').all(is_portable_component) { + return Err(invalid("unsafe NeoForge artifact path")); + } + let target = root.join(relative); + for path in target.ancestors() { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(invalid(format!( + "symlink in NeoForge path: {}", + path.display() + ))); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(target) +} + +fn bounded_read(path: &Path) -> Result, NeoForgeError> { + let mut bytes = Vec::new(); + fs::File::open(path)? + .take(MAX_METADATA + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_METADATA { + return Err(invalid("NeoForge metadata is too large")); + } + Ok(bytes) +} + +fn embedded(archive: &mut zip::ZipArchive, name: &str) -> Result, NeoForgeError> { + let entry = archive + .by_name(name) + .map_err(|error| invalid(error.to_string()))?; + let mut bytes = Vec::new(); + entry.take(MAX_METADATA + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_METADATA { + return Err(invalid("embedded NeoForge metadata is too large")); + } + Ok(bytes) +} + +fn hash_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn valid_version(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && is_portable_component(value) + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b".-_".contains(&b)) +} + +/// Only provider-owned Maven outputs are published. Archive paths, absolute +/// arguments, ROOT substitutions and client-controlled filenames are rejected. +fn coordinate_path(coordinate: &str) -> Result { + let coordinate = coordinate + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .ok_or_else(|| invalid("unsupported NeoForge output coordinate"))?; + let (coordinate, extension) = coordinate.split_once('@').unwrap_or((coordinate, "jar")); + let parts: Vec<_> = coordinate.split(':').collect(); + if !(3..=4).contains(&parts.len()) + || !parts.iter().all(|s| valid_version(s)) + || !matches!(parts[0], "net.minecraft" | "net.neoforged") + || !matches!(extension, "jar" | "txt") + { + return Err(invalid("unsupported NeoForge output coordinate")); + } + let classifier = parts.get(3).map(|v| format!("-{v}")).unwrap_or_default(); + let relative = format!( + "libraries/{}/{}/{}/{}-{}{classifier}.{extension}", + parts[0].replace('.', "/"), + parts[1], + parts[2], + parts[1], + parts[2] + ); + if !relative.split('/').all(is_portable_component) { + return Err(invalid("unsafe generated NeoForge coordinate")); + } + Ok(relative) +} + +fn data_value<'a>(data: &'a Value, argument: &'a str) -> Result<&'a str, NeoForgeError> { + if let Some(key) = argument.strip_prefix('{').and_then(|s| s.strip_suffix('}')) { + data.get(key) + .and_then(|v| v.get("client")) + .and_then(Value::as_str) + .ok_or_else(|| invalid(format!("missing client recipe value: {key}"))) + } else { + Ok(argument) + } +} + +struct Recipe { + version_bytes: Vec, + version_relative: String, + outputs: BTreeMap>, + identity: Identity, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +struct Identity { + schema: u32, + loader: String, + minecraft: String, + installer_sha256: String, + recipe_sha256: String, + vanilla_sha1: String, + vanilla_size: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +struct Receipt { + identity: Identity, + files: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize)] +struct FileDigest { + size: u64, + sha256: String, +} + +impl Recipe { + fn read(installer: &Path, loader: &str, vanilla: &VersionJson) -> Result { + if !valid_version(loader) || !valid_version(&vanilla.id) { + return Err(invalid("invalid Minecraft or NeoForge version")); + } + let mut archive = zip::ZipArchive::new(fs::File::open(installer)?) + .map_err(|error| invalid(error.to_string()))?; + let profile_bytes = embedded(&mut archive, "install_profile.json")?; + let version_bytes = embedded(&mut archive, "version.json")?; + let profile: Value = + serde_json::from_slice(&profile_bytes).map_err(NeoForgeError::InvalidJson)?; + let version: Value = + serde_json::from_slice(&version_bytes).map_err(NeoForgeError::InvalidJson)?; + let id = format!("neoforge-{loader}"); + if profile["spec"] != 1 + || profile["version"] != id + || profile["minecraft"] != vanilla.id + || profile["json"] != "/version.json" + || version["id"] != id + || version["inheritsFrom"] != vanilla.id + { + return Err(invalid( + "installer recipe does not match selected Minecraft/NeoForge", + )); + } + serde_json::from_slice::(&version_bytes) + .map_err(NeoForgeError::InvalidJson)?; + let data = &profile["data"]; + let processors = profile["processors"] + .as_array() + .ok_or_else(|| invalid("missing processors"))?; + let mut outputs = BTreeMap::new(); + for processor in processors { + if let Some(sides) = processor.get("sides") { + let sides = sides + .as_array() + .ok_or_else(|| invalid("invalid processor sides"))?; + if !sides.iter().any(|side| side == "client") { + continue; + } + } + let args = processor["args"] + .as_array() + .ok_or_else(|| invalid("missing processor args"))?; + for pair in args.windows(2) { + if matches!(pair[0].as_str(), Some("--output" | "--slim" | "--extra")) { + let argument = pair[1] + .as_str() + .ok_or_else(|| invalid("invalid output argument"))?; + let path = coordinate_path(data_value(data, argument)?)?; + outputs.entry(path).or_insert(None); + } + } + if let Some(expected) = processor.get("outputs") { + for (argument, digest) in expected + .as_object() + .ok_or_else(|| invalid("invalid processor outputs"))? + { + let path = coordinate_path(data_value(data, argument)?)?; + let digest = data_value( + data, + digest + .as_str() + .ok_or_else(|| invalid("invalid output hash"))?, + )?; + let digest = digest + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .unwrap_or(digest); + if digest.len() != 40 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(invalid("unsupported processor output checksum")); + } + if let Some(Some(existing)) = outputs.get(&path) { + if existing != &digest.to_ascii_lowercase() { + return Err(invalid("conflicting output hashes")); + } + } + outputs.insert(path, Some(digest.to_ascii_lowercase())); + } + } + } + let mut portable_paths = BTreeSet::new(); + if outputs + .keys() + .any(|path| !portable_paths.insert(path.to_ascii_lowercase())) + { + return Err(invalid( + "generated output paths collide on a case-insensitive filesystem", + )); + } + let patched = coordinate_path(data_value(data, "{PATCHED}")?)?; + let expected_patched = + format!("libraries/net/neoforged/neoforge/{loader}/neoforge-{loader}-client.jar"); + let extra = coordinate_path(data_value(data, "{MC_EXTRA}")?)?; + if patched != expected_patched + || !outputs.contains_key(&patched) + || !outputs.contains_key(&extra) + { + return Err(invalid( + "unsupported recipe: missing patched client or extra output", + )); + } + let client = &vanilla + .downloads + .as_ref() + .ok_or_else(|| invalid("missing verified vanilla download"))? + .client; + if client.size == 0 + || client.sha1.len() != 40 + || !client.sha1.bytes().all(|b| b.is_ascii_hexdigit()) + { + return Err(invalid("invalid verified vanilla identity")); + } + Ok(Self { + version_relative: format!("versions/{id}/{id}.json"), + version_bytes, + outputs, + identity: Identity { + schema: 1, + loader: loader.into(), + minecraft: vanilla.id.clone(), + installer_sha256: download::file_hashes(installer)?.1, + recipe_sha256: hash_bytes(&profile_bytes), + vanilla_sha1: client.sha1.to_ascii_lowercase(), + vanilla_size: client.size, + }, + }) + } + + fn paths(&self) -> impl Iterator { + self.outputs + .keys() + .chain(std::iter::once(&self.version_relative)) + } + + fn current(&self, root: &Path, receipt_path: &Path) -> Result { + let receipt = match bounded_read(receipt_path) { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(receipt) => receipt, + Err(_) => return Ok(false), + }, + Err(NeoForgeError::Io(e)) if e.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(NeoForgeError::InvalidInstallation(_)) => return Ok(false), + Err(error) => return Err(error), + }; + if receipt.identity != self.identity || receipt.files.len() != self.outputs.len() + 1 { + return Ok(false); + } + // Enumerate trusted recipe paths, never paths claimed by the local receipt. + for relative in self.paths() { + let Some(digest) = receipt.files.get(relative) else { + return Ok(false); + }; + let path = safe_path(root, relative)?; + if !download::is_current( + &path, + Some(digest.size), + &download::Checksum::Sha256(digest.sha256.clone()), + )? { + return Ok(false); + } + } + Ok(bounded_read(&safe_path(root, &self.version_relative)?)? == self.version_bytes) + } +} + +struct Stage(PathBuf); +impl Stage { + fn new(cache: &Path) -> Result { + for _ in 0..128 { + let name = format!( + "neoforge-stage-{}-{}", + std::process::id(), + NEXT_STAGE.fetch_add(1, Ordering::Relaxed) + ); + let path = safe_path(cache, &name)?; + fs::create_dir_all(cache)?; + match fs::create_dir(&path) { + Ok(()) => return Ok(Self(path)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error.into()), + } + } + Err(invalid("cannot create NeoForge staging directory")) + } +} +impl Drop for Stage { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn atomic_copy(source: &Path, target: &Path) -> Result<(), NeoForgeError> { + let mut source = fs::File::open(source)?; + let mut output = storage::AtomicFile::new(target)?; + io::copy(&mut source, output.writer())?; + output.commit()?; + Ok(()) +} + +fn validate_output(path: &Path, expected_sha1: Option<&str>) -> Result { + let metadata = fs::metadata(path)?; + if !metadata.is_file() || metadata.len() == 0 { + return Err(invalid("empty generated artifact")); + } + if path.extension().is_some_and(|ext| ext == "jar") { + let mut archive = zip::ZipArchive::new(fs::File::open(path)?) + .map_err(|error| invalid(error.to_string()))?; + if archive.is_empty() { + return Err(invalid("empty generated jar")); + } + // Reading every entry validates ZIP checksums, not just its directory. + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|error| invalid(error.to_string()))?; + io::copy(&mut entry, &mut io::sink())?; + } + } + let (sha1, sha256) = download::file_hashes(path)?; + if expected_sha1.is_some_and(|expected| !expected.eq_ignore_ascii_case(&sha1)) { + return Err(invalid( + "generated artifact differs from recipe output checksum", + )); + } + Ok(FileDigest { + size: metadata.len(), + sha256, + }) +} + +/// `installer` is supplied only by ensure_installer, after fixed-host SHA-256 +/// verification. Tests inject a synthetic archive and a bounded fake runner. +/// A failed promotion has no receipt; next invocation rebuilds from scratch. +pub(super) fn ensure( + installer: &Path, + game: &Path, + cache: &Path, + loader: &str, + vanilla: &VersionJson, + run: impl FnOnce(&Path) -> Result<(), NeoForgeError>, +) -> Result { + let recipe = Recipe::read(installer, loader, vanilla)?; + let receipt_path = safe_path(cache, &format!("neoforge-receipts/{loader}.json"))?; + if recipe.current(game, &receipt_path)? { + return serde_json::from_slice(&recipe.version_bytes).map_err(NeoForgeError::InvalidJson); + } + // Invalidate before changing any output. Even interruption during multi-file + // promotion cannot leave a complete receipt over a partial installation. + match fs::remove_file(&receipt_path) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + // Validate all destination paths before invoking the installer. + for relative in recipe.paths() { + safe_path(game, relative)?; + } + let stage = Stage::new(cache)?; + ensure_launcher_profiles_stub(&stage.0)?; + let vanilla_relative = format!("versions/{0}/{0}.jar", vanilla.id); + let source = safe_path(game, &vanilla_relative)?; + let input = safe_path(&stage.0, &vanilla_relative)?; + atomic_copy(&source, &input)?; + if !download::is_current( + &input, + Some(recipe.identity.vanilla_size), + &download::Checksum::Sha1(recipe.identity.vanilla_sha1.clone()), + )? { + return Err(invalid( + "vanilla input changed or was not verified before NeoForge installation", + )); + } + run(&stage.0)?; + let version = safe_path(&stage.0, &recipe.version_relative)?; + if bounded_read(&version)? != recipe.version_bytes { + return Err(invalid("installer produced unexpected version JSON")); + } + let mut files = BTreeMap::new(); + for (relative, sha1) in &recipe.outputs { + files.insert( + relative.clone(), + validate_output(&safe_path(&stage.0, relative)?, sha1.as_deref())?, + ); + } + files.insert( + recipe.version_relative.clone(), + FileDigest { + size: recipe.version_bytes.len() as u64, + sha256: hash_bytes(&recipe.version_bytes), + }, + ); + for relative in recipe.paths() { + atomic_copy(&safe_path(&stage.0, relative)?, &safe_path(game, relative)?)?; + } + let receipt = Receipt { + identity: recipe.identity, + files, + }; + storage::write_atomic( + &receipt_path, + &serde_json::to_vec(&receipt).map_err(NeoForgeError::InvalidJson)?, + )?; + // Use the same expected bytes for merge as for receipt validation. + debug_assert_eq!( + installed_version_json_path(game, loader), + game.join(&recipe.version_relative) + ); + serde_json::from_slice(&recipe.version_bytes).map_err(NeoForgeError::InvalidJson) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + cell::Cell, + io::{Cursor, Write}, + }; + use zip::write::SimpleFileOptions; + + const LOADER: &str = "21.1.248"; + + fn jar_bytes(contents: &[u8]) -> Vec { + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + zip.start_file("fixture.class", SimpleFileOptions::default()) + .unwrap(); + zip.write_all(contents).unwrap(); + zip.finish().unwrap().into_inner() + } + + struct Fixture { + _root: Stage, + installer: PathBuf, + game: PathBuf, + cache: PathBuf, + vanilla: VersionJson, + profile: Value, + version: Vec, + } + + impl Fixture { + fn new() -> Self { + let root = Stage::new(&std::env::temp_dir()).unwrap(); + let game = root.0.join("game"); + let cache = root.0.join("cache"); + let input = jar_bytes(b"verified vanilla"); + let input_path = game.join("versions/1.21.1/1.21.1.jar"); + fs::create_dir_all(input_path.parent().unwrap()).unwrap(); + fs::write(&input_path, &input).unwrap(); + let vanilla = serde_json::from_value(serde_json::json!({ + "id":"1.21.1", "mainClass":"Main", "downloads":{"client":{ + "sha1":download::file_hashes(&input_path).unwrap().0, + "size":input.len(), "url":"https://piston-data.mojang.com/client.jar" + }} + })) + .unwrap(); + let profile = serde_json::json!({ + "spec":1, "version":"neoforge-21.1.248", "minecraft":"1.21.1", "json":"/version.json", + "data":{ + "PATCHED":{"client":"[net.neoforged:neoforge:21.1.248:client]"}, + "MC_EXTRA":{"client":"[net.minecraft:client:1.21.1-20240808.144430:extra]"}, + "MAPPINGS":{"client":"[net.neoforged:neoform:1.21.1-20240808.144430:mappings@txt]"} + }, + "processors":[ + {"sides":["server"],"args":["--output","{ROOT}/run.sh"]}, + {"args":["--output","{MAPPINGS}"]}, + {"sides":["client"],"args":["--extra","{MC_EXTRA}"]}, + {"args":["--output","{PATCHED}"]} + ], "libraries":[] + }); + let version = serde_json::to_vec(&serde_json::json!({ + "id":"neoforge-21.1.248", "inheritsFrom":"1.21.1", "mainClass":"Main", "libraries":[] + })).unwrap(); + let fixture = Self { + installer: root.0.join("installer.jar"), + _root: root, + game, + cache, + vanilla, + profile, + version, + }; + fixture.write_installer(); + fixture + } + + fn write_installer(&self) { + let mut archive = zip::ZipWriter::new(fs::File::create(&self.installer).unwrap()); + for (name, bytes) in [ + ( + "install_profile.json", + serde_json::to_vec(&self.profile).unwrap(), + ), + ("version.json", self.version.clone()), + ] { + archive + .start_file(name, SimpleFileOptions::default()) + .unwrap(); + archive.write_all(&bytes).unwrap(); + } + archive.finish().unwrap(); + } + + fn receipt(&self) -> PathBuf { + self.cache.join("neoforge-receipts/21.1.248.json") + } + fn patched(&self) -> PathBuf { + self.game + .join("libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar") + } + fn run( + &self, + runner: impl FnOnce(&Path) -> Result<(), NeoForgeError>, + ) -> Result { + ensure( + &self.installer, + &self.game, + &self.cache, + LOADER, + &self.vanilla, + runner, + ) + } + fn produce(&self, stage: &Path) -> Result<(), NeoForgeError> { + let recipe = Recipe::read(&self.installer, LOADER, &self.vanilla)?; + for relative in recipe.outputs.keys() { + let path = stage.join(relative); + assert!( + !path.exists(), + "must never reuse old generated files in staging" + ); + fs::create_dir_all(path.parent().unwrap())?; + fs::write( + &path, + if relative.ends_with(".jar") { + jar_bytes(b"clean generated output") + } else { + b"mappings".to_vec() + }, + )?; + } + let version = stage.join(recipe.version_relative); + fs::create_dir_all(version.parent().unwrap())?; + fs::write(version, &self.version)?; + Ok(()) + } + } + + #[test] + fn legacy_is_rebuilt_and_healthy_receipt_skips_runner() { + let f = Fixture::new(); + fs::create_dir_all(f.patched().parent().unwrap()).unwrap(); + fs::write(f.patched(), b"legacy corrupt nonempty jar").unwrap(); + let profile_file = f._root.0.join("profiles/aeronautics/mods/user.jar"); + fs::create_dir_all(profile_file.parent().unwrap()).unwrap(); + fs::write(&profile_file, b"user mod").unwrap(); + f.run(|stage| f.produce(stage)).unwrap(); + assert!(f.receipt().exists()); + assert_ne!( + fs::read(f.patched()).unwrap(), + b"legacy corrupt nonempty jar" + ); + f.run(|_| panic!("healthy receipt must not run installer")) + .unwrap(); + assert_eq!(fs::read(profile_file).unwrap(), b"user mod"); + } + + #[test] + fn nonempty_json_and_jar_corruption_trigger_clean_rebuild() { + let f = Fixture::new(); + f.run(|stage| f.produce(stage)).unwrap(); + for bytes in [ + b"broken json".as_slice(), + br#"{"id":"neoforge-21.1.248","mainClass":"Wrong"}"#, + ] { + fs::write(installed_version_json_path(&f.game, LOADER), bytes).unwrap(); + let called = Cell::new(false); + f.run(|stage| { + called.set(true); + f.produce(stage) + }) + .unwrap(); + assert!(called.get()); + } + for bytes in [ + b"not a zip".to_vec(), + jar_bytes(b"changed but valid zip"), + Vec::new(), + ] { + fs::write(f.patched(), bytes).unwrap(); + let called = Cell::new(false); + f.run(|stage| { + called.set(true); + f.produce(stage) + }) + .unwrap(); + assert!(called.get()); + } + fs::remove_file(f.patched()).unwrap(); + f.run(|stage| f.produce(stage)).unwrap(); + } + + #[test] + fn corrupt_vanilla_input_is_rejected_before_processors() { + let f = Fixture::new(); + fs::write(f.game.join("versions/1.21.1/1.21.1.jar"), b"corrupt input").unwrap(); + assert!(f + .run(|_| panic!("must verify vanilla before running processors")) + .is_err()); + assert!(!f.receipt().exists()); + } + + #[test] + fn failed_runner_and_invalid_outputs_do_not_create_receipt() { + let f = Fixture::new(); + assert!(f + .run(|_| Err(invalid("simulated installer failure"))) + .is_err()); + assert!(!f.receipt().exists()); + assert!(f + .run(|stage| { + f.produce(stage)?; + fs::write( + stage.join( + "libraries/net/neoforged/neoforge/21.1.248/neoforge-21.1.248-client.jar", + ), + b"nonempty damaged jar", + )?; + Ok(()) + }) + .is_err()); + assert!(!f.receipt().exists()); + f.run(|stage| f.produce(stage)).unwrap(); + } + + #[test] + fn missing_commit_marker_after_partial_promotion_forces_rebuild() { + let f = Fixture::new(); + f.run(|stage| f.produce(stage)).unwrap(); + // Equivalent persisted state to interruption after one promoted file. + fs::remove_file(f.receipt()).unwrap(); + fs::write(f.patched(), jar_bytes(b"partially promoted generation")).unwrap(); + let called = Cell::new(false); + f.run(|stage| { + called.set(true); + f.produce(stage) + }) + .unwrap(); + assert!(called.get()); + f.run(|_| panic!("recovered generation must be complete")) + .unwrap(); + } + + #[test] + fn receipt_cannot_invent_output_paths_and_changed_recipe_rebuilds() { + let mut f = Fixture::new(); + f.run(|stage| f.produce(stage)).unwrap(); + let mut receipt: Value = serde_json::from_slice(&fs::read(f.receipt()).unwrap()).unwrap(); + receipt["files"]["../../user.jar"] = serde_json::json!({"size":1,"sha256":"00"}); + fs::write(f.receipt(), serde_json::to_vec(&receipt).unwrap()).unwrap(); + f.run(|stage| f.produce(stage)).unwrap(); + f.profile["recipeChange"] = Value::Bool(true); + f.write_installer(); + let called = Cell::new(false); + f.run(|stage| { + called.set(true); + f.produce(stage) + }) + .unwrap(); + assert!(called.get()); + } + + #[test] + fn recipe_version_output_paths_and_authoritative_hashes_are_enforced() { + let mut f = Fixture::new(); + f.profile["minecraft"] = Value::String("1.20.1".into()); + f.write_installer(); + assert!(f.run(|_| panic!("wrong version recipe")).is_err()); + f.profile["minecraft"] = Value::String("1.21.1".into()); + f.profile["data"]["PATCHED"]["client"] = + Value::String("[net.neoforged:neoforge:../escape:client]".into()); + f.write_installer(); + assert!(f.run(|_| panic!("escaping output")).is_err()); + f.profile["data"]["PATCHED"]["client"] = + Value::String("[net.neoforged:neoforge:21.1.248:client]".into()); + f.profile["processors"][3]["outputs"] = + serde_json::json!({"{PATCHED}":"0000000000000000000000000000000000000000"}); + f.write_installer(); + assert!(f.run(|stage| f.produce(stage)).is_err()); + assert!(!f.receipt().exists()); + } + + #[cfg(unix)] + #[test] + fn output_symlinks_are_rejected_without_touching_target() { + let f = Fixture::new(); + let outside = f._root.0.join("outside"); + fs::create_dir(&outside).unwrap(); + fs::write(outside.join("user"), b"untouched").unwrap(); + std::os::unix::fs::symlink(&outside, f.game.join("libraries")).unwrap(); + assert!(f + .run(|_| panic!("symlink rejected before installer")) + .is_err()); + assert_eq!(fs::read(outside.join("user")).unwrap(), b"untouched"); + assert!(!f.receipt().exists()); + } +} diff --git a/src-tauri/src/operations.rs b/src-tauri/src/operations.rs index 5e73c32..d8a1fc8 100644 --- a/src-tauri/src/operations.rs +++ b/src-tauri/src/operations.rs @@ -3,12 +3,14 @@ //! Acquire before scheduling the worker and move the permit into it. Dropping //! the caller's future cannot unlock an operation that is still running. use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, }; #[derive(Default)] pub(crate) struct LauncherOperations { + pub lifecycle: Lifecycle, + recovery: Mutex>, pub installation: Operation, pub account: Operation, pub shacraft_account: Operation, @@ -50,3 +52,62 @@ mod tests { assert!(operation.acquire("Installation").is_ok()); } } + +// Owned, Send permits are held by the worker, not the awaiting IPC future. +// Readers represent all native operations which can persist or launch; the +// sole writer represents launcher replacement, including its download stage. +#[derive(Default)] +pub(crate) struct Lifecycle(Arc); +const EXCLUSIVE: usize = usize::MAX; +impl Lifecycle { + pub fn shared(&self) -> Result { + self.0 + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| { + if n < EXCLUSIVE - 1 { + Some(n + 1) + } else { + None + } + }) + .map_err(|_| "Лаунчер обновляется. Дождитесь завершения и перезапуска.".to_string())?; + Ok(SharedPermit(self.0.clone())) + } + pub fn exclusive(&self) -> Result { + self.0 + .compare_exchange(0, EXCLUSIVE, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| { + "Завершите операцию с игрой, настройками или аккаунтом и повторите обновление." + .to_string() + })?; + Ok(ExclusivePermit(self.0.clone())) + } + pub fn is_updating(&self) -> bool { + self.0.load(Ordering::Acquire) == EXCLUSIVE + } +} +pub(crate) struct SharedPermit(Arc); +impl Drop for SharedPermit { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Release); + } +} +pub(crate) struct ExclusivePermit(Arc); +impl Drop for ExclusivePermit { + fn drop(&mut self) { + self.0.store(0, Ordering::Release); + } +} + +impl LauncherOperations { + /// Startup recovery is latched for this process. Removing a marker in a + /// running application is never authority to resume writes or launch. + pub fn latch_recovery(&self, reason: String) { + *self.recovery.lock().unwrap() = Some(reason); + } + pub fn ensure_writable(&self) -> Result<(), String> { + match self.recovery.lock().unwrap().as_ref() { + Some(reason) => Err(reason.clone()), + None => Ok(()), + } + } +} diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs index 59e91ae..2557190 100644 --- a/src-tauri/src/profile.rs +++ b/src-tauri/src/profile.rs @@ -1,8 +1,12 @@ use crate::download::{self, Checksum, DownloadError}; +use crate::inventory::{self, Change, Fingerprint, Inventory, OwnedFile, Store}; use crate::manifest::{is_allowed_download_url, FilePolicy, ManagedFile, Manifest}; use reqwest::{blocking::Client, redirect::Policy}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; +#[cfg(test)] +use sha2::{Digest, Sha256}; use std::{ + collections::{BTreeSet, HashSet}, fmt, fs, io, path::{Path, PathBuf}, time::Duration, @@ -15,6 +19,10 @@ pub struct ProfileInspection { pub managed_files: usize, pub missing_files: usize, pub mismatched_files: usize, + pub stale_files: usize, + pub conflicts: Vec, + pub pending_update: bool, + pub legacy_files: usize, pub up_to_date: bool, } @@ -25,6 +33,7 @@ pub struct SyncResult { pub downloaded_files: usize, pub reused_files: usize, pub downloaded_bytes: u64, + pub removed_files: usize, } #[derive(Debug)] @@ -33,54 +42,121 @@ pub enum ProfileError { Network(reqwest::Error), Download { path: String, source: DownloadError }, UnsafePath(PathBuf), + Conflict(Vec), } - impl fmt::Display for ProfileError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Io(error) => write!(formatter, "Cannot access profile: {error}"), Self::Network(error) => write!(formatter, "Cannot download profile file: {error}"), - Self::Download { path, source } => { - write!(formatter, "Download failed for {path}: {source}") - } - Self::UnsafePath(path) => write!( - formatter, - "Profile path contains a symbolic link: {}", - path.display() - ), + Self::Download { path, source } => write!(formatter, "Download failed for {path}: {source}"), + Self::UnsafePath(path) => write!(formatter, "Unsafe or protected profile path: {}", path.display()), + Self::Conflict(paths) => write!(formatter, "Файлы изменены или не принадлежат лаунчеру; сохранены без изменений: {}. Проверьте список пользовательских модов.", paths.join(", ")), } } } +fn expected_fingerprint(file: &ManagedFile) -> Fingerprint { + Fingerprint { + size: file.size, + sha256: file.sha256.to_ascii_lowercase(), + } +} +#[cfg(test)] +fn manifest_snapshot(manifest: &Manifest) -> String { + // Internal identity of the already verified manifest's installation inputs. + // This fingerprint never substitutes for remote signature verification. + let mut hash = Sha256::new(); + for value in [ + &manifest.id, + &manifest.minecraft.version, + &manifest.minecraft.loader.kind, + &manifest.minecraft.loader.version, + ] { + hash.update(value.as_bytes()); + hash.update([0]); + } + hash.update([manifest.minecraft.java_major]); + for file in &manifest.files { + for value in [&file.path, &file.url, &file.sha256] { + hash.update(value.as_bytes()); + hash.update([0]); + } + hash.update(file.size.to_le_bytes()); + hash.update([if file.policy == FilePolicy::Managed { + 1 + } else { + 2 + }]); + } + format!("{:x}", hash.finalize()) +} +fn current(root: &Path, relative: &str) -> Result, ProfileError> { + inventory::fingerprint(&managed_target(root, relative)?).map_err(ProfileError::Io) +} + pub fn inspect(root: &Path, manifest: &Manifest) -> Result { + let store = Store::open(root).map_err(ProfileError::Io)?; + let owned = store.load().map_err(ProfileError::Io)?; + let pending_update = store.pending().map_err(ProfileError::Io)?; let mut missing_files = 0; let mut mismatched_files = 0; - + let mut conflicts = Vec::new(); + let paths: HashSet<_> = manifest + .files + .iter() + .map(|file| file.path.as_str()) + .collect(); for expected in &manifest.files { - let path = managed_target(root, &expected.path)?; - if !path.try_exists().map_err(ProfileError::Io)? { + let actual = current(root, &expected.path)?; + if actual.is_none() { missing_files += 1; continue; } - if matches!(expected.policy, FilePolicy::Seed) && path.is_file() { + if expected.policy == FilePolicy::Seed { continue; } - let checksum = Checksum::Sha256(expected.sha256.clone()); - if !download::is_current(&path, Some(expected.size), &checksum).map_err(ProfileError::Io)? { + if actual.as_ref() != Some(&expected_fingerprint(expected)) { mismatched_files += 1; + if owned.files.get(&expected.path).map(|f| &f.fingerprint) != actual.as_ref() { + conflicts.push(expected.path.clone()); + } } } - + let mut stale_files = 0; + for (path, previous) in &owned.files { + if paths.contains(path.as_str()) { + continue; + } + if let Some(actual) = current(root, path)? { + stale_files += 1; + if actual != previous.fingerprint { + conflicts.push(path.clone()); + } + } + } + let legacy_files = legacy_mods(root, manifest, &owned)?.len(); Ok(ProfileInspection { root: root.display().to_string(), managed_files: manifest.files.len(), missing_files, mismatched_files, - up_to_date: missing_files == 0 && mismatched_files == 0, + stale_files, + pending_update, + legacy_files, + up_to_date: missing_files == 0 + && mismatched_files == 0 + && stale_files == 0 + && conflicts.is_empty() + && !pending_update, + conflicts, }) } -pub fn sync(root: &Path, manifest: &Manifest) -> Result { +pub fn sync_snapshot( + root: &Path, + snapshot: &crate::remote::VerifiedSnapshot, +) -> Result { let client = Client::builder() .connect_timeout(Duration::from_secs(15)) .timeout(Duration::from_secs(10 * 60)) @@ -95,64 +171,309 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result Result { - let mut path = root.to_path_buf(); - if let Some(parent) = root.parent() { - reject_symlink(parent)?; - } - reject_symlink(&path)?; - for component in relative.split('/') { - path.push(component); - reject_symlink(&path)?; - } - Ok(path) +#[cfg(test)] +fn sync_with( + root: &Path, + manifest: &Manifest, + download: impl FnMut(&ManagedFile, &Path) -> Result, +) -> Result { + sync_with_snapshot(root, manifest, &manifest_snapshot(manifest), download) } -fn reject_symlink(path: &Path) -> Result<(), ProfileError> { - match fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_symlink() => { - Err(ProfileError::UnsafePath(path.to_path_buf())) +fn sync_with_snapshot( + root: &Path, + manifest: &Manifest, + snapshot: &str, + mut download: impl FnMut(&ManagedFile, &Path) -> Result, +) -> Result { + let store = Store::open(root).map_err(ProfileError::Io)?; + store.recover().map_err(ProfileError::Io)?; + let previous = store.load().map_err(ProfileError::Io)?; + let mut next = previous.clone(); + let mut conflicts = Vec::new(); + let mut changes = Vec::new(); + let mut downloads: Vec<(usize, &ManagedFile)> = Vec::new(); + let mut reused_files = 0; + let mut removed_files = 0; + let paths: HashSet<_> = manifest + .files + .iter() + .map(|file| file.path.as_str()) + .collect(); + for expected in &manifest.files { + let actual = current(root, &expected.path)?; + let fingerprint = expected_fingerprint(expected); + if expected.policy == FilePolicy::Seed && actual.is_some() { + next.files.remove(&expected.path); // relinquish managed -> seed, preserving edits + reused_files += 1; + continue; + } + if actual.as_ref() == Some(&fingerprint) { + // Matching legacy bytes prove content, never launcher ownership. + if previous.files.get(&expected.path).map(|f| &f.fingerprint) != actual.as_ref() { + next.files.remove(&expected.path); + } + reused_files += 1; + continue; + } + if actual.is_some() + && previous.files.get(&expected.path).map(|f| &f.fingerprint) != actual.as_ref() + { + conflicts.push(expected.path.clone()); + continue; + } + downloads.push((changes.len(), expected)); + changes.push(Change { + path: expected.path.clone(), + before: actual, + after: Some(fingerprint.clone()), + }); + if expected.policy == FilePolicy::Managed { + next.files.insert( + expected.path.clone(), + OwnedFile { + fingerprint, + snapshot: snapshot.to_owned(), + }, + ); + } else { + next.files.remove(&expected.path); } - Ok(_) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(ProfileError::Io(error)), } + for (path, previous_file) in &previous.files { + if paths.contains(path.as_str()) { + continue; + } + match current(root, path)? { + None => { + next.files.remove(path); + } + Some(actual) if actual == previous_file.fingerprint => { + changes.push(Change { + path: path.clone(), + before: Some(actual), + after: None, + }); + next.files.remove(path); + removed_files += 1; + } + Some(_) => conflicts.push(path.clone()), + } + } + if !conflicts.is_empty() { + return Err(ProfileError::Conflict(conflicts)); + } + if changes.is_empty() && next == previous { + return Ok(SyncResult { + root: root.display().to_string(), + downloaded_files: 0, + reused_files, + downloaded_bytes: 0, + removed_files: 0, + }); + } + let transaction = store.transaction().map_err(ProfileError::Io)?; + let mut downloaded_bytes = 0; + for (index, file) in &downloads { + let stage = store + .stage(&transaction, *index) + .map_err(ProfileError::Io)?; + downloaded_bytes += download(file, &stage)?; + if inventory::fingerprint(&stage) + .map_err(ProfileError::Io)? + .as_ref() + != Some(&expected_fingerprint(file)) + { + return Err(ProfileError::Io(inventory::invalid( + "Staged profile file failed verification", + ))); + } + } + // Persist the whole future ownership set before the first payload change. + store + .prepare(transaction, changes, next) + .map_err(ProfileError::Io)?; + store.recover().map_err(ProfileError::Io)?; + Ok(SyncResult { + root: root.display().to_string(), + downloaded_files: downloads.len(), + reused_files, + downloaded_bytes, + removed_files, + }) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyMod { + pub path: String, + pub size: u64, + pub sha256: String, + pub reason: &'static str, +} +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LegacySelection { + pub path: String, + pub sha256: String, +} +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyBackup { + pub backup_root: String, + pub files: Vec, +} + +fn legacy_mods( + root: &Path, + manifest: &Manifest, + owned: &Inventory, +) -> Result, ProfileError> { + let mods = managed_target(root, "mods")?; + let entries = match fs::read_dir(mods) { + Ok(entries) => entries, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(ProfileError::Io(e)), + }; + let mut result = Vec::new(); + for entry in entries { + let entry = entry.map_err(ProfileError::Io)?; + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if !name.to_ascii_lowercase().ends_with(".jar") + || !crate::manifest::is_portable_component(&name) + { + continue; + } + let path = format!("mods/{name}"); + let expected = manifest + .files + .iter() + .find(|f| f.path.eq_ignore_ascii_case(&path)); + if expected.is_some_and(|f| f.policy == FilePolicy::Seed) { + continue; + } + let actual = current(root, &path)? + .ok_or_else(|| ProfileError::Io(inventory::invalid("Mod changed during inspection")))?; + if owned + .files + .get(&path) + .is_some_and(|f| f.fingerprint == actual) + { + continue; + } + if expected.is_some_and(|f| expected_fingerprint(f) == actual) { + continue; + } + let reason = if owned.files.contains_key(&path) { + "changed_managed" + } else if expected.is_some() { + "conflicts_with_pack" + } else { + "not_in_pack" + }; + result.push(LegacyMod { + path, + size: actual.size, + sha256: actual.sha256, + reason, + }); + } + result.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(result) +} + +/// Informational list, not ownership evidence. Unknown extra mods remain in +/// place; callers must show the file/hash and obtain an explicit selection. +pub fn list_legacy_mods(root: &Path, manifest: &Manifest) -> Result, ProfileError> { + let store = Store::open(root).map_err(ProfileError::Io)?; + if store.pending().map_err(ProfileError::Io)? { + return Err(ProfileError::Io(inventory::invalid( + "Finish recovery before reviewing legacy mods", + ))); + } + legacy_mods(root, manifest, &store.load().map_err(ProfileError::Io)?) +} + +/// Moves only currently listed, explicitly chosen JARs with the reviewed hash. +/// No arbitrary local path, ownership adoption, seed or personal data cleanup. +pub fn backup_legacy_mods( + root: &Path, + manifest: &Manifest, + selections: &[LegacySelection], +) -> Result { + if selections.is_empty() { + return Err(ProfileError::Io(inventory::invalid( + "Select at least one reviewed mod", + ))); + } + let store = Store::open(root).map_err(ProfileError::Io)?; + store.recover().map_err(ProfileError::Io)?; + let owned = store.load().map_err(ProfileError::Io)?; + let candidates = legacy_mods(root, manifest, &owned)?; + let mut unique = BTreeSet::new(); + let mut changes = Vec::new(); + for selection in selections { + if !unique.insert(selection.path.clone()) { + return Err(ProfileError::Io(inventory::invalid( + "Duplicate selected mod", + ))); + } + let candidate = candidates + .iter() + .find(|c| c.path == selection.path && c.sha256 == selection.sha256) + .ok_or_else(|| { + ProfileError::Io(inventory::invalid( + "Selected mod changed or is no longer eligible; review the list again", + )) + })?; + changes.push(Change { + path: candidate.path.clone(), + before: Some(Fingerprint { + size: candidate.size, + sha256: candidate.sha256.clone(), + }), + after: None, + }); + } + let transaction = store.transaction().map_err(ProfileError::Io)?; + let backup_root = store + .root + .join(&transaction) + .join("backup") + .display() + .to_string(); + // Retain a path-to-index map alongside backups after the journal completes. + let map = serde_json::to_vec(&selections.iter().map(|s| &s.path).collect::>()) + .map_err(|e| ProfileError::Io(inventory::invalid(e.to_string())))?; + crate::storage::write_atomic(&store.root.join(&transaction).join("files.json"), &map) + .map_err(ProfileError::Io)?; + let mut next = owned; + for selection in selections { + next.files.remove(&selection.path); + } + store + .prepare(transaction, changes, next) + .map_err(ProfileError::Io)?; + store.recover().map_err(ProfileError::Io)?; + Ok(LegacyBackup { + backup_root, + files: selections.iter().map(|s| s.path.clone()).collect(), + }) +} + +fn managed_target(root: &Path, relative: &str) -> Result { + if inventory::protected(relative) { + return Err(ProfileError::UnsafePath(root.join(relative))); + } + inventory::checked_path(root, relative) + .map_err(|_| ProfileError::UnsafePath(root.join(relative))) } fn download_managed_file( @@ -160,18 +481,17 @@ fn download_managed_file( expected: &ManagedFile, target: &Path, ) -> Result { - let checksum = Checksum::Sha256(expected.sha256.clone()); download::download_verified( client, &expected.url, target, Some(expected.size), - &checksum, + &Checksum::Sha256(expected.sha256.clone()), |_, _| {}, ) - .map_err(|error| ProfileError::Download { + .map_err(|source| ProfileError::Download { path: expected.path.clone(), - source: error, + source, }) } @@ -179,6 +499,7 @@ fn download_managed_file( mod tests { use super::inspect; use crate::manifest::{FilePolicy, Loader, ManagedFile, Manifest, Minecraft}; + #[cfg(test)] use sha2::{Digest, Sha256}; use std::{ fs, process, @@ -297,3 +618,331 @@ mod tests { fs::remove_dir_all(root).unwrap(); } } + +#[cfg(test)] +mod update_tests { + use super::*; + use crate::manifest::{Loader, Minecraft}; + use std::{ + collections::BTreeMap, + sync::atomic::{AtomicU64, Ordering}, + }; + static NEXT: AtomicU64 = AtomicU64::new(0); + struct Fixture { + base: PathBuf, + root: PathBuf, + } + impl Fixture { + fn new() -> Self { + let base = std::env::temp_dir().join(format!( + "shacraft-update-{}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + let root = base.join("profiles/aeronautics"); + fs::create_dir_all(&root).unwrap(); + Self { base, root } + } + fn write(&self, path: &str, bytes: &[u8]) { + let target = self.root.join(path); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(target, bytes).unwrap(); + } + fn bytes(&self, path: &str) -> Vec { + fs::read(self.root.join(path)).unwrap() + } + fn sync( + &self, + manifest: &Manifest, + files: &[(&str, &[u8])], + ) -> Result { + let content: BTreeMap<_, _> = files.iter().copied().collect(); + sync_with(&self.root, manifest, |file, target| { + let bytes = content + .get(file.path.as_str()) + .expect("unexpected download"); + fs::write(target, bytes).map_err(ProfileError::Io)?; + Ok(bytes.len() as u64) + }) + } + } + impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.base).unwrap(); + } + } + fn pack(files: &[(&str, &[u8], FilePolicy)]) -> Manifest { + Manifest { + schema_version: 1, + id: "aeronautics".into(), + display_name: "Aeronautics".into(), + minecraft: Minecraft { + version: "1.21.1".into(), + loader: Loader { + kind: "neoforge".into(), + version: "21.1.248".into(), + }, + java_major: 21, + }, + files: files + .iter() + .map(|(path, bytes, policy)| ManagedFile { + path: (*path).into(), + url: format!("https://cdn.shacraft.ru/{path}"), + sha256: format!("{:x}", Sha256::digest(bytes)), + size: bytes.len() as u64, + policy: *policy, + }) + .collect(), + } + } + + #[test] + fn renamed_owned_mod_is_backed_up_and_user_mod_and_seed_survive() { + let f = Fixture::new(); + let a = pack(&[ + ("mods/one-1.jar", b"old", FilePolicy::Managed), + ("config/seed.txt", b"default", FilePolicy::Seed), + ]); + f.sync( + &a, + &[("mods/one-1.jar", b"old"), ("config/seed.txt", b"default")], + ) + .unwrap(); + f.write("mods/personal.jar", b"mine"); + f.write("config/seed.txt", b"edits"); + let b = pack(&[("mods/one-2.jar", b"new", FilePolicy::Managed)]); + let inspection = inspect(&f.root, &b).unwrap(); + assert_eq!(inspection.stale_files, 1); + assert!(!inspection.up_to_date); + let synced = f.sync(&b, &[("mods/one-2.jar", b"new")]).unwrap(); + assert_eq!(synced.removed_files, 1); + assert!(!f.root.join("mods/one-1.jar").exists()); + assert_eq!(f.bytes("mods/one-2.jar"), b"new"); + assert_eq!(f.bytes("mods/personal.jar"), b"mine"); + assert_eq!(f.bytes("config/seed.txt"), b"edits"); + let store = Store::open(&f.root).unwrap(); + let owned = store.load().unwrap(); + assert_eq!( + owned.files.keys().collect::>(), + vec!["mods/one-2.jar"] + ); + let state_backups: Vec<_> = fs::read_dir(&store.root) + .unwrap() + .filter_map(Result::ok) + .map(|e| e.path().join("backup/1")) + .filter(|p| p.exists()) + .collect(); + assert_eq!(state_backups.len(), 1); + assert_eq!(fs::read(&state_backups[0]).unwrap(), b"old"); + assert!(inspect(&f.root, &b).unwrap().up_to_date); + } + #[test] + fn missing_inventory_never_adopts_matching_or_extra_legacy_files() { + let f = Fixture::new(); + f.write("mods/current.jar", b"pack"); + f.write("mods/old.jar", b"legacy"); + let a = pack(&[("mods/current.jar", b"pack", FilePolicy::Managed)]); + let result = f.sync(&a, &[]).unwrap(); + assert_eq!(result.reused_files, 1); + assert!(Store::open(&f.root) + .unwrap() + .load() + .unwrap() + .files + .is_empty()); + f.sync(&pack(&[]), &[]).unwrap(); + assert_eq!(f.bytes("mods/current.jar"), b"pack"); + assert_eq!(f.bytes("mods/old.jar"), b"legacy"); + let list = list_legacy_mods(&f.root, &a).unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0].path, "mods/old.jar"); + } + #[test] + fn changed_owned_file_blocks_replacement_and_retirement_without_mutation() { + let f = Fixture::new(); + let a = pack(&[("mods/current.jar", b"pack", FilePolicy::Managed)]); + f.sync(&a, &[("mods/current.jar", b"pack")]).unwrap(); + f.write("mods/current.jar", b"player edits"); + let b = pack(&[("mods/current.jar", b"next", FilePolicy::Managed)]); + assert!(matches!(f.sync(&b, &[]), Err(ProfileError::Conflict(_)))); + assert!(matches!( + f.sync(&pack(&[]), &[]), + Err(ProfileError::Conflict(_)) + )); + assert_eq!(f.bytes("mods/current.jar"), b"player edits"); + let inspection = inspect(&f.root, &b).unwrap(); + assert!(!inspection.up_to_date); + assert_eq!(inspection.conflicts, vec!["mods/current.jar"]); + assert_eq!( + list_legacy_mods(&f.root, &b).unwrap()[0].reason, + "changed_managed" + ); + } + #[test] + fn failed_staging_changes_no_payload_or_ownership() { + let f = Fixture::new(); + let a = pack(&[("mods/current.jar", b"old", FilePolicy::Managed)]); + f.sync(&a, &[("mods/current.jar", b"old")]).unwrap(); + let b = pack(&[ + ("mods/current.jar", b"new", FilePolicy::Managed), + ("mods/second.jar", b"two", FilePolicy::Managed), + ]); + let mut downloads = 0; + let result = sync_with(&f.root, &b, |_, path| { + downloads += 1; + if downloads == 2 { + return Err(ProfileError::Io(io::Error::other("network failed"))); + } + fs::write(path, b"new").unwrap(); + Ok(3) + }); + assert!(result.is_err()); + assert_eq!(f.bytes("mods/current.jar"), b"old"); + assert!(!f.root.join("mods/second.jar").exists()); + assert!(!Store::open(&f.root).unwrap().pending().unwrap()); + assert!(inspect(&f.root, &a).unwrap().up_to_date); + f.sync( + &b, + &[("mods/current.jar", b"new"), ("mods/second.jar", b"two")], + ) + .unwrap(); + assert!(inspect(&f.root, &b).unwrap().up_to_date); + } + #[test] + fn seed_policy_transitions_preserve_user_edits_and_do_not_adopt_seed() { + let f = Fixture::new(); + let a = pack(&[("config/file.txt", b"old", FilePolicy::Managed)]); + f.sync(&a, &[("config/file.txt", b"old")]).unwrap(); + f.write("config/file.txt", b"custom"); + let seeded = pack(&[("config/file.txt", b"default", FilePolicy::Seed)]); + f.sync(&seeded, &[]).unwrap(); + assert!(Store::open(&f.root) + .unwrap() + .load() + .unwrap() + .files + .is_empty()); + assert_eq!(f.bytes("config/file.txt"), b"custom"); + assert!(matches!(f.sync(&a, &[]), Err(ProfileError::Conflict(_)))); + f.sync(&pack(&[]), &[]).unwrap(); + assert_eq!(f.bytes("config/file.txt"), b"custom"); + } + #[test] + fn explicit_legacy_backup_requires_current_hash_and_preserves_every_other_file() { + let f = Fixture::new(); + f.write("mods/old.jar", b"old"); + f.write("mods/keep.jar", b"keep"); + f.write("mods/seed.jar", b"seed edits"); + f.write("saves/world/level.dat", b"world"); + let manifest = pack(&[("mods/seed.jar", b"seed", FilePolicy::Seed)]); + let candidates = list_legacy_mods(&f.root, &manifest).unwrap(); + assert_eq!(candidates.len(), 2); + let old = candidates + .iter() + .find(|c| c.path == "mods/old.jar") + .unwrap(); + let invalid = [LegacySelection { + path: old.path.clone(), + sha256: "0".repeat(64), + }]; + assert!(backup_legacy_mods(&f.root, &manifest, &invalid).is_err()); + let traversal = [LegacySelection { + path: "../outside.jar".into(), + sha256: old.sha256.clone(), + }]; + assert!(backup_legacy_mods(&f.root, &manifest, &traversal).is_err()); + let chosen = [LegacySelection { + path: old.path.clone(), + sha256: old.sha256.clone(), + }]; + let backup = backup_legacy_mods(&f.root, &manifest, &chosen).unwrap(); + assert_eq!( + fs::read(Path::new(&backup.backup_root).join("0")).unwrap(), + b"old" + ); + assert!(!f.root.join("mods/old.jar").exists()); + assert_eq!(f.bytes("mods/keep.jar"), b"keep"); + assert_eq!(f.bytes("mods/seed.jar"), b"seed edits"); + assert_eq!(f.bytes("saves/world/level.dat"), b"world"); + assert!(Store::open(&f.root) + .unwrap() + .load() + .unwrap() + .files + .is_empty()); + } + #[test] + fn new_publication_recovers_and_retires_files_from_interrupted_previous_update() { + let f = Fixture::new(); + let store = Store::open(&f.root).unwrap(); + let transaction = store.transaction().unwrap(); + let before = pack(&[("mods/intermediate.jar", b"middle", FilePolicy::Managed)]); + let fingerprint = expected_fingerprint(&before.files[0]); + let stage = store.stage(&transaction, 0).unwrap(); + fs::write(&stage, b"middle").unwrap(); + let mut next = Inventory::default(); + next.files.insert( + "mods/intermediate.jar".into(), + OwnedFile { + fingerprint: fingerprint.clone(), + snapshot: manifest_snapshot(&before), + }, + ); + store + .prepare( + transaction, + vec![Change { + path: "mods/intermediate.jar".into(), + before: None, + after: Some(fingerprint), + }], + next, + ) + .unwrap(); + fs::create_dir_all(f.root.join("mods")).unwrap(); + fs::rename(stage, f.root.join("mods/intermediate.jar")).unwrap(); + let after = pack(&[("mods/final.jar", b"final", FilePolicy::Managed)]); + let result = f.sync(&after, &[("mods/final.jar", b"final")]).unwrap(); + assert_eq!(result.removed_files, 1); + assert!(!f.root.join("mods/intermediate.jar").exists()); + assert_eq!(f.bytes("mods/final.jar"), b"final"); + assert!(inspect(&f.root, &after).unwrap().up_to_date); + assert!(!store.pending().unwrap()); + } + + #[test] + fn unknown_collision_requires_review_before_install_and_protected_paths_are_refused() { + let f = Fixture::new(); + f.write("mods/current.jar", b"unknown"); + let manifest = pack(&[("mods/current.jar", b"pack", FilePolicy::Managed)]); + assert!(matches!( + f.sync(&manifest, &[]), + Err(ProfileError::Conflict(_)) + )); + let list = list_legacy_mods(&f.root, &manifest).unwrap(); + backup_legacy_mods( + &f.root, + &manifest, + &[LegacySelection { + path: list[0].path.clone(), + sha256: list[0].sha256.clone(), + }], + ) + .unwrap(); + f.sync(&manifest, &[("mods/current.jar", b"pack")]).unwrap(); + assert!(Store::open(&f.root) + .unwrap() + .load() + .unwrap() + .files + .contains_key("mods/current.jar")); + let unsafe_manifest = pack(&[("screenshots/player.png", b"bad", FilePolicy::Managed)]); + assert!(f.sync(&unsafe_manifest, &[]).is_err()); + assert!(!f.root.join("screenshots/player.png").exists()); + } +} diff --git a/src-tauri/src/remote.rs b/src-tauri/src/remote.rs index 0cd269f..2e77155 100644 --- a/src-tauri/src/remote.rs +++ b/src-tauri/src/remote.rs @@ -4,6 +4,7 @@ use ed25519_dalek::{Signature, VerifyingKey}; use reqwest::header::ACCEPT_ENCODING; use reqwest::{blocking::Client, redirect::Policy}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::{ fmt, io::{self, Read}, @@ -68,7 +69,16 @@ impl fmt::Display for RemoteError { } } +pub struct VerifiedSnapshot { + pub manifest: Manifest, + pub digest: String, +} + pub fn fetch_manifest(profile_id: &str) -> Result { + Ok(fetch_snapshot(profile_id)?.manifest) +} + +pub fn fetch_snapshot(profile_id: &str) -> Result { let url = match profile_id { "aeronautics" => AERONAUTICS_MANIFEST, _ => return Err(RemoteError::UnknownProfile), @@ -90,7 +100,7 @@ pub fn fetch_manifest(profile_id: &str) -> Result { .expect("embedded public key must be 32 bytes"), ) .expect("embedded public key must be valid"); - verify_envelope(&source, profile_id, &public_key) + verify_snapshot(&source, profile_id, &public_key) } fn fetch_manifest_bytes(client: &Client, url: &str) -> Result, RemoteError> { @@ -154,11 +164,20 @@ fn read_envelope(source: impl Read) -> Result, RemoteError> { Ok(bytes) } +#[cfg(test)] fn verify_envelope( source: &[u8], profile_id: &str, public_key: &VerifyingKey, ) -> Result { + Ok(verify_snapshot(source, profile_id, public_key)?.manifest) +} + +fn verify_snapshot( + source: &[u8], + profile_id: &str, + public_key: &VerifyingKey, +) -> Result { if source.len() > MAX_ENVELOPE_BYTES { return Err(RemoteError::TooLarge); } @@ -178,12 +197,13 @@ fn verify_envelope( public_key .verify_strict(&payload, &signature) .map_err(|_| RemoteError::InvalidSignature)?; + let digest = format!("{:x}", Sha256::digest(&payload)); let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?; let manifest = manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)?; if manifest.id != profile_id { return Err(RemoteError::ProfileMismatch); } - Ok(manifest) + Ok(VerifiedSnapshot { manifest, digest }) } #[cfg(test)] diff --git a/src-tauri/src/shacraft_account.rs b/src-tauri/src/shacraft_account.rs index 39fb801..56a9fbf 100644 --- a/src-tauri/src/shacraft_account.rs +++ b/src-tauri/src/shacraft_account.rs @@ -45,9 +45,21 @@ pub struct LoginResult { #[derive(Clone, Deserialize, Serialize)] pub struct LinkStart { + pub proof_version: u32, + pub server_id: String, pub challenge_id: i64, pub expires_in_seconds: u64, pub registered_on_server: bool, + pub proof_code: String, + pub mc_username: String, + pub player_uuid: String, +} + +#[derive(Deserialize)] +pub struct OnboardingGrant { + #[serde(flatten)] + pub challenge: LinkStart, + pub grant_token: String, } #[derive(Clone, Deserialize, Serialize)] @@ -203,7 +215,92 @@ pub fn start_link( if !response.status().is_success() { return Err(api_error(response)); } - response.json::().map_err(AccountError::Network) + let value = response + .json::() + .map_err(AccountError::Network)?; + validate_challenge(&value, server_id, nickname)?; + Ok(value) +} + +pub fn valid_nickname(name: &str) -> bool { + (3..=16).contains(&name.len()) && name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_') +} + +fn validate_challenge( + value: &LinkStart, + server_id: &str, + requested: &str, +) -> Result<(), AccountError> { + if value.proof_version != 1 + || value.server_id != server_id + || !valid_nickname(requested) + || value.mc_username != requested + || value.player_uuid != crate::session::offline_uuid(requested) + || value.challenge_id <= 0 + || value.expires_in_seconds == 0 + || value.expires_in_seconds > 600 + || value.proof_code.len() != 32 + || !value.proof_code.bytes().all(|c| c.is_ascii_hexdigit()) + { + return Err(AccountError::Api( + "Сервер вернул неподходящее подтверждение ника".into(), + )); + } + Ok(()) +} + +pub fn start_onboarding(data_dir: &Path, nickname: &str) -> Result { + if !valid_nickname(nickname) { + return Err(AccountError::Api("Неверный игровой ник".into())); + } + let response = client()? + .post(format!("{API_ORIGIN}/api/launcher/onboarding/start")) + .bearer_auth(load_session(data_dir)?) + .json(&serde_json::json!({"server_id":"aoc","mc_username":nickname})) + .send() + .map_err(AccountError::Network)?; + if !response.status().is_success() { + return Err(api_error(response)); + } + let grant: OnboardingGrant = response.json().map_err(AccountError::Network)?; + validate_challenge(&grant.challenge, "aoc", nickname)?; + if grant.grant_token.len() < 32 + || grant.grant_token.len() > 256 + || !grant + .grant_token + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + { + return Err(AccountError::Api( + "Некорректное разрешение первого входа".into(), + )); + } + Ok(grant) +} + +pub fn validate_onboarding(data_dir: &Path, grant: &OnboardingGrant) -> Result<(), AccountError> { + let response = client()?.post(format!("{API_ORIGIN}/api/launcher/onboarding/validate")) + .bearer_auth(load_session(data_dir)?) + .json(&serde_json::json!({"challenge_id":grant.challenge.challenge_id,"grant_token":grant.grant_token})) + .send().map_err(AccountError::Network)?; + if !response.status().is_success() { + return Err(api_error(response)); + } + let data: serde_json::Value = response.json().map_err(AccountError::Network)?; + if data["server_id"] != "aoc" + || data["mc_username"] != grant.challenge.mc_username + || data["player_uuid"] != grant.challenge.player_uuid + || data["challenge_id"] != grant.challenge.challenge_id + || data["proof_version"] != 1 + || !data["expires_in_seconds"] + .as_u64() + .is_some_and(|n| n > 0 && n <= 600) + { + return Err(AccountError::Api( + "Первый вход не подтверждён сервером".into(), + )); + } + Ok(()) } pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result { @@ -238,6 +335,39 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; + #[test] + fn challenge_requires_matching_server_exact_nickname_uuid_and_bounded_nonce() { + let valid = super::LinkStart { + proof_version: 1, + server_id: "aoc".into(), + challenge_id: 1, + expires_in_seconds: 600, + registered_on_server: false, + proof_code: "a".repeat(32), + mc_username: "ShaCraft_Test".into(), + player_uuid: crate::session::offline_uuid("ShaCraft_Test"), + }; + assert!(super::validate_challenge(&valid, "aoc", "ShaCraft_Test").is_ok()); + assert!(super::validate_challenge(&valid, "create", "ShaCraft_Test").is_err()); + assert!(super::validate_challenge(&valid, "aoc", "shacraft_test").is_err()); + let mut wrong = valid.clone(); + wrong.player_uuid = crate::session::offline_uuid("shacraft_test"); + assert!(super::validate_challenge(&wrong, "aoc", "ShaCraft_Test").is_err()); + for ttl in [0, 601] { + wrong = valid.clone(); + wrong.expires_in_seconds = ttl; + assert!(super::validate_challenge(&wrong, "aoc", "ShaCraft_Test").is_err()); + } + wrong = valid.clone(); + wrong.proof_version = 0; + assert!(super::validate_challenge(&wrong, "aoc", "ShaCraft_Test").is_err()); + for code in ["a".repeat(31), "g".repeat(32), "a".repeat(33)] { + wrong = valid.clone(); + wrong.proof_code = code; + assert!(super::validate_challenge(&wrong, "aoc", "ShaCraft_Test").is_err()); + } + } + fn temporary_directory() -> std::path::PathBuf { std::env::temp_dir().join(format!( "shacraft-account-test-{}-{}", diff --git a/src-tauri/src/update_guard.rs b/src-tauri/src/update_guard.rs new file mode 100644 index 0000000..930f0e4 --- /dev/null +++ b/src-tauri/src/update_guard.rs @@ -0,0 +1,355 @@ +//! Launcher replacement is a distinct trust and lifecycle boundary from game +//! installation. A process-local gate drains all native writes; the game lock +//! also checks the durable detached-game lease. A handoff record survives the +//! Windows updater's immediate process exit and is never cleared by a timeout. +use crate::{ + installation_lock::InstallationLock, + operations::{ExclusivePermit, LauncherOperations, Permit}, +}; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File, OpenOptions}, + io, + path::{Path, PathBuf}, +}; + +// A renamed durable marker must survive a power loss before installer handoff. +// Unix directory fsync persists the name itself, in addition to write_atomic's +// fsync of the file contents. Windows uses its native file replacement semantics. +fn sync_directory(path: &Path) -> Result<(), String> { + #[cfg(unix)] + { + File::open(path) + .and_then(|file| file.sync_all()) + .map_err(|e| e.to_string())?; + } + #[cfg(not(unix))] + let _ = path; + Ok(()) +} + +const RECOVERY: &str = "Предыдущая установка обновления не завершена или её запись повреждена. Автоматическое продолжение заблокировано. Закройте игру, установщик и лаунчер, затем восстановите приложение официальным пакетом того же типа. Настройки и игровые файлы удалять не нужно."; + +fn ordinary(path: &Path) -> Result<(), String> { + match fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => { + Err("Служебный путь обновления является ссылкой".into()) + } + Ok(_) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.to_string()), + } +} + +fn directory(data: &Path) -> Result { + ordinary(data)?; + fs::create_dir_all(data).map_err(|e| e.to_string())?; + let path = data.join("launcher-state"); + ordinary(&path)?; + fs::create_dir_all(&path).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).map_err(|e| e.to_string())?; + } + sync_directory(data)?; + if let Some(parent) = data.parent() { + sync_directory(parent)?; + } + Ok(path) +} + +/// Retained in Tauri state for the entire process lifetime. In particular an +/// idle second launcher cannot keep an old executable loaded during replacement. +pub(crate) struct InstanceGuard { + _file: File, + recovery_reason: Option, +} +impl Drop for InstanceGuard { + fn drop(&mut self) { + // Release the owner lock even if a concurrent spawn retains a temporary + // inherited file description before exec. Never clear the handoff marker. + let _ = self._file.unlock(); + } +} + +impl InstanceGuard { + pub fn recovery_reason(&self) -> Option { + self.recovery_reason.clone() + } + pub fn acquire(data: &Path, current_version: &str) -> Result { + let dir = directory(data)?; + let path = dir.join("instance.lock"); + ordinary(&path)?; + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(&path).map_err(|e| e.to_string())?; + let marker = dir.join("pending-update.json"); + let mut acquired = file.try_lock().is_ok(); + if !acquired && read_pending(&marker)?.is_some_and(|pending| pending.to == current_version) + { + // Tauri starts the replacement child before its old process exits. + // Only the exact recorded target may wait for that legitimate handoff. + // Never remove a lock or treat elapsed time as successful acquisition. + let until = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !acquired && std::time::Instant::now() < until { + std::thread::sleep(std::time::Duration::from_millis(25)); + acquired = file.try_lock().is_ok(); + } + } + if !acquired { + return Err( + "ShaCraft Launcher уже запущен. Откройте его окно или завершите другой экземпляр." + .into(), + ); + } + let recovery_reason = match read_pending(&marker) { + // A corrupt/unreadable marker becomes explicit recovery state, + // NEVER Ready/None. Setup latches this reason before any IPC runs. + Err(reason) => Some(reason), + Ok(Some(pending)) if pending.to == current_version => fs::remove_file(marker) + .map_err(|error| error.to_string()) + .and_then(|()| sync_directory(&dir)) + .err() + .map(|error| format!("{RECOVERY} {error}")), + Ok(Some(pending)) => Some(format!("{RECOVERY} Ожидаемая версия: {}.", pending.to)), + Ok(None) => None, + }; + Ok(Self { + _file: file, + recovery_reason, + }) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PendingUpdate { + from: String, + to: String, +} + +fn read_pending(path: &Path) -> Result, String> { + ordinary(path)?; + match fs::read(path) { + Ok(bytes) if bytes.len() <= 1024 => serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| RECOVERY.to_string()), + Ok(_) => Err(RECOVERY.into()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.to_string()), + } +} + +pub(crate) fn ensure_no_pending(data: &Path) -> Result<(), String> { + if let Some(pending) = read_pending(&directory(data)?.join("pending-update.json"))? { + return Err(format!("{RECOVERY} Ожидаемая версия: {}.", pending.to)); + } + Ok(()) +} + +pub(crate) fn pending_reason( + data: &Path, + _current_version: &str, +) -> Result, String> { + // Startup is the only place allowed to acknowledge a completed handoff. + // Read-only status must never remove a marker while an installer is active. + match ensure_no_pending(data) { + Ok(()) => Ok(None), + Err(reason) => Ok(Some(reason)), + } +} + +pub(crate) fn begin_operation( + data: &Path, + operations: &LauncherOperations, +) -> Result { + operations.ensure_writable()?; + let permit = operations.lifecycle.shared()?; + ensure_no_pending(data)?; + Ok(permit) +} + +pub(crate) struct UpdateGuard<'a> { + operations: &'a LauncherOperations, + directory: PathBuf, + _exclusive: ExclusivePermit, + _operation: Permit, + _installation: InstallationLock, +} +impl<'a> UpdateGuard<'a> { + pub fn acquire(data: &Path, operations: &'a LauncherOperations) -> Result { + operations.ensure_writable()?; + let exclusive = operations.lifecycle.exclusive()?; + let operation = operations.installation.acquire("Обновление лаунчера")?; + ensure_no_pending(data)?; + let installation = InstallationLock::acquire(data)?; + Ok(Self { + operations, + directory: directory(data)?, + _exclusive: exclusive, + _operation: operation, + _installation: installation, + }) + } + + /// Call only after all package checks and immediately before the platform + /// installer. Drop intentionally preserves this record on uncertain errors. + pub fn begin_install(&self, current_version: &str, target_version: &str) -> Result<(), String> { + let from = semver::Version::parse(current_version).map_err(|e| e.to_string())?; + let to = semver::Version::parse(target_version).map_err(|e| e.to_string())?; + if to <= from || !to.pre.is_empty() || !to.build.is_empty() { + return Err("Установка этой версии обновления запрещена".into()); + } + let path = self.directory.join("pending-update.json"); + ordinary(&path)?; + crate::storage::write_atomic( + &path, + &serde_json::to_vec(&PendingUpdate { + from: current_version.into(), + to: target_version.into(), + }) + .map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + self.operations + .latch_recovery(format!("{RECOVERY} Ожидаемая версия: {target_version}.")); + sync_directory(&self.directory) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + fn dir() -> PathBuf { + std::env::temp_dir().join(format!( + "shacraft-update-lock-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )) + } + + #[test] + fn instance_owner_drop_releases_lock_despite_inherited_description() { + let data = dir(); + let owner = InstanceGuard::acquire(&data, "0.2.0").unwrap(); + let inherited = owner._file.try_clone().unwrap(); + assert!(InstanceGuard::acquire(&data, "0.2.0").is_err()); + drop(owner); + let next = InstanceGuard::acquire(&data, "0.2.0") + .unwrap_or_else(|error| panic!("owner instance lock remained: {error}")); + drop(inherited); + drop(next); + fs::remove_dir_all(data).unwrap(); + } + + #[test] + fn excludes_account_settings_and_game_writes_in_both_directions() { + let dir = dir(); + let state = LauncherOperations::default(); + let write = begin_operation(&dir, &state).unwrap(); + assert!(UpdateGuard::acquire(&dir, &state).is_err()); + drop(write); + let update = UpdateGuard::acquire(&dir, &state).unwrap(); + assert!(begin_operation(&dir, &state).is_err()); + assert!(UpdateGuard::acquire(&dir, &state).is_err()); + drop(update); + assert!(begin_operation(&dir, &state).is_ok()); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn checks_game_lease_after_launcher_has_exited() { + let dir = dir(); + let state = LauncherOperations::default(); + let game = InstallationLock::acquire(&dir).unwrap(); + game.running(std::process::id()).unwrap(); + drop(game); + assert!(UpdateGuard::acquire(&dir, &state).is_err()); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn failed_download_can_retry_but_installer_handoff_survives_exit() { + let dir = dir(); + let state = LauncherOperations::default(); + drop(UpdateGuard::acquire(&dir, &state).unwrap()); + let update = UpdateGuard::acquire(&dir, &state).unwrap(); + assert!(update.begin_install("0.2.0", "0.1.1").is_err()); + assert!(update.begin_install("0.2.0", "0.2.0").is_err()); + update.begin_install("0.2.0", "0.2.1").unwrap(); + drop(update); + assert!(begin_operation(&dir, &state).is_err()); + assert!(UpdateGuard::acquire(&dir, &state).is_err()); + let old = InstanceGuard::acquire(&dir, "0.2.0").unwrap(); + assert!(begin_operation(&dir, &state).is_err()); + drop(old); + let different = InstanceGuard::acquire(&dir, "0.3.0").unwrap(); + assert!(begin_operation(&dir, &state).is_err()); + drop(different); + let updated = InstanceGuard::acquire(&dir, "0.2.1").unwrap(); + assert!(begin_operation(&dir, &state).is_err()); // old process stays latched + assert!(begin_operation(&dir, &LauncherOperations::default()).is_ok()); + drop(updated); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn target_process_waits_for_real_lock_release_during_restart() { + let dir = dir(); + let instance = InstanceGuard::acquire(&dir, "0.2.0").unwrap(); + let state = LauncherOperations::default(); + let update = UpdateGuard::acquire(&dir, &state).unwrap(); + update.begin_install("0.2.0", "0.2.1").unwrap(); + drop(update); + // Model Tauri's spawn-before-exit with an OS lock held by the old owner. + let next_dir = dir.clone(); + let next = std::thread::spawn(move || InstanceGuard::acquire(&next_dir, "0.2.1")); + std::thread::sleep(std::time::Duration::from_millis(75)); + assert!(!next.is_finished()); + drop(instance); + let target = next.join().unwrap().unwrap(); + assert!(target.recovery_reason().is_none()); + assert!(begin_operation(&dir, &state).is_err()); + assert!(begin_operation(&dir, &LauncherOperations::default()).is_ok()); + drop(target); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn rejects_second_idle_instance_and_malformed_marker() { + let dir = dir(); + let instance = InstanceGuard::acquire(&dir, "0.2.0").unwrap(); + assert!(InstanceGuard::acquire(&dir, "0.2.0").is_err()); + drop(instance); + fs::write( + directory(&dir).unwrap().join("pending-update.json"), + b"invalid", + ) + .unwrap(); + let recovery = InstanceGuard::acquire(&dir, "0.2.1").unwrap(); + let state = LauncherOperations::default(); + state.latch_recovery( + recovery + .recovery_reason() + .expect("corruption must be explicit recovery"), + ); + // Read-only diagnostics are available, while every shared write and + // updater install remains denied, including after external deletion. + assert!(pending_reason(&dir, "0.2.1").unwrap().is_some()); + assert!(begin_operation(&dir, &state).is_err()); + assert!(UpdateGuard::acquire(&dir, &state).is_err()); + fs::remove_file(directory(&dir).unwrap().join("pending-update.json")).unwrap(); + assert!(begin_operation(&dir, &state).is_err()); + assert!(UpdateGuard::acquire(&dir, &state).is_err()); + drop(recovery); + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs new file mode 100644 index 0000000..9e112ae --- /dev/null +++ b/src-tauri/src/updater.rs @@ -0,0 +1,664 @@ +//! Native-only launcher updater. The webview controls timing, never trust inputs. +mod format; +mod protocol; +use crate::{ + operations::{LauncherOperations, Operation, Permit}, + update_guard::UpdateGuard, +}; +use protocol::{Artifact, VerifiedRelease}; +use reqwest::blocking::Client; +use serde::Serialize; +use std::{ + path::Path, + process::{Command, Stdio}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +#[cfg(target_os = "linux")] +use tauri::Manager; +use tauri::{AppHandle, Emitter}; +use tauri_plugin_updater::UpdaterExt; + +#[derive(Clone, Serialize, Debug, PartialEq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Phase { + Idle, + Checking, + Available, + Downloading, + Verifying, + Installing, + Ready, + NoUpdate, + Unconfigured, + Manual, + Error, +} + +#[derive(Clone, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateStatus { + revision: u64, + installed_version: String, + package_format: &'static str, + phase: Phase, + available_version: Option, + release_notes: Option, + downloaded_bytes: u64, + total_bytes: Option, + can_retry: bool, + message: Option, + test_build: bool, +} + +struct StateData { + status: UpdateStatus, + candidate: Option, +} + +#[derive(Clone)] +pub(crate) struct UpdaterState { + inner: Arc>, + operation: Operation, +} +impl Default for UpdaterState { + fn default() -> Self { + let configured = configured_key().is_some(); + Self { + inner: Arc::new(Mutex::new(StateData { + status: UpdateStatus { + revision: 0, + installed_version: env!("CARGO_PKG_VERSION").into(), + package_format: format::installed_label(), + phase: if configured { Phase::Idle } else { Phase::Unconfigured }, + available_version: None, release_notes: None, downloaded_bytes: 0, total_bytes: None, + can_retry: false, + message: (!configured).then(|| "Подписанные обновления ещё не настроены для этой сборки. Официальные выпуски доступны на GitHub.".into()), + test_build: protocol::test_build(), + }, + candidate: None, + })), + operation: Operation::default(), + } + } +} + +pub(crate) fn configured_key() -> Option<&'static str> { + protocol::configured_key() +} + +impl UpdaterState { + pub fn status(&self) -> UpdateStatus { + self.inner.lock().unwrap().status.clone() + } + pub fn acquire(&self) -> Result { + self.operation.acquire("Обновление лаунчера") + } + fn change(&self, app: &AppHandle, update: impl FnOnce(&mut StateData)) -> UpdateStatus { + let status = { + let mut data = self.inner.lock().unwrap(); + update(&mut data); + data.status.revision += 1; + data.status.clone() + }; + let _ = app.emit("launcher-update-status", &status); + status + } + fn phase(&self, app: &AppHandle, phase: Phase, message: Option) -> UpdateStatus { + self.change(app, |data| { + data.status.can_retry = phase == Phase::Error; + data.status.phase = phase; + data.status.message = message; + }) + } + pub fn fail(&self, app: &AppHandle, error: String) -> UpdateStatus { + self.change(app, |data| { + data.status.can_retry = data.status.phase != Phase::Installing; + data.status.phase = Phase::Error; + data.status.message = Some(error); + if !data.status.can_retry { + data.candidate = None; + } + }) + } + pub fn recovery(&self, app: &AppHandle, reason: String) -> UpdateStatus { + self.change(app, |data| { + data.candidate = None; + data.status.phase = Phase::Error; + data.status.can_retry = false; + data.status.message = Some(reason); + }) + } + pub fn may_check(&self) -> bool { + !matches!( + self.status().phase, + Phase::Downloading | Phase::Verifying | Phase::Installing | Phase::Ready + ) + } + pub fn ready(&self) -> bool { + self.status().phase == Phase::Ready + } + pub fn critical(&self) -> bool { + matches!( + self.status().phase, + Phase::Downloading | Phase::Verifying | Phase::Installing | Phase::Ready + ) + } +} + +#[derive(Clone, Debug, PartialEq)] +enum PackageMode { + Automatic { platform: &'static str, msi: bool }, + Manual, +} + +fn package_mode( + os: &str, + arch: &str, + bundle: Option, +) -> PackageMode { + use tauri::utils::config::BundleType; + match (os, arch, bundle) { + ("linux", "x86_64", Some(BundleType::AppImage)) => PackageMode::Automatic { + platform: "linux-x86_64", + msi: false, + }, + ("windows", "x86_64", Some(BundleType::Nsis)) => PackageMode::Automatic { + platform: "windows-x86_64", + msi: false, + }, + ("windows", "x86_64", Some(BundleType::Msi)) => PackageMode::Automatic { + platform: "windows-x86_64", + msi: true, + }, + ("macos", "aarch64", Some(BundleType::App)) => PackageMode::Automatic { + platform: "darwin-aarch64", + msi: false, + }, + ("macos", "x86_64", Some(BundleType::App)) => PackageMode::Automatic { + platform: "darwin-x86_64", + msi: false, + }, + _ => PackageMode::Manual, + } +} + +// The stamped bundle type survives extracting an AppImage. It does not identify +// the runtime file which the plugin will replace. Only the frozen Tauri Env is +// shared with the plugin's executable_path selection; do not reread process env. +#[cfg(any(target_os = "linux", test))] +fn appimage_context_valid(path: Option<&Path>, ordinary_file: bool, header: &[u8]) -> bool { + path.is_some_and(Path::is_absolute) + && ordinary_file + && format::verify( + &PackageMode::Automatic { + platform: "linux-x86_64", + msi: false, + }, + header, + ) + .is_ok() +} + +#[cfg(any(target_os = "linux", test))] +fn appimage_file_ready(path: Option<&Path>) -> bool { + use std::io::Read; + let Some(path) = path else { return false }; + let ordinary_file = std::fs::symlink_metadata(path) + .map(|metadata| metadata.file_type().is_file()) + .unwrap_or(false); + if !path.is_absolute() || !ordinary_file { + return false; + } + let mut header = [0; 20]; + if std::fs::File::open(path) + .and_then(|mut file| file.read_exact(&mut header)) + .is_err() + { + return false; + } + appimage_context_valid(Some(path), ordinary_file, &header) +} + +// Binding APPDIR to the actual executable rejects APPIMAGE/APPDIR inherited +// from an unrelated parent application. The fixed relative path is the verified +// Tauri AppDir layout for this application's configured binary name. +#[cfg(any(target_os = "linux", test))] +fn appdir_matches_executable(appdir: Option<&Path>, executable: Option<&Path>) -> bool { + let (Some(appdir), Some(executable)) = (appdir, executable) else { + return false; + }; + if !appdir.is_absolute() || !executable.is_absolute() { + return false; + } + let (Ok(appdir), Ok(executable)) = (appdir.canonicalize(), executable.canonicalize()) else { + return false; + }; + appdir.is_dir() + && executable.is_file() + && executable.strip_prefix(&appdir).ok() == Some(Path::new("usr/bin/shacraft-launcher")) +} + +#[cfg(target_os = "linux")] +fn appimage_runtime_ready(app: &AppHandle) -> bool { + let environment = app.env(); + let executable = std::env::current_exe().ok(); + appimage_file_ready(environment.appimage.as_deref().map(Path::new)) + && appdir_matches_executable( + environment.appdir.as_deref().map(Path::new), + executable.as_deref(), + ) +} + +fn current_mode(_app: &AppHandle) -> PackageMode { + // Bare binaries, distro packages and dev runs must never be overwritten as an AppImage/.app. + if cfg!(debug_assertions) { + return PackageMode::Manual; + } + #[cfg(target_os = "linux")] + if !appimage_runtime_ready(_app) { + return PackageMode::Manual; + } + #[cfg(target_os = "macos")] + { + let app_bundle = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(|p| p.ends_with("Contents/MacOS"))) + .unwrap_or(false); + if !app_bundle { + return PackageMode::Manual; + } + } + package_mode( + std::env::consts::OS, + std::env::consts::ARCH, + tauri::utils::platform::bundle_type(), + ) +} + +fn client() -> Result { + Client::builder() + .https_only(true) + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(300)) + .user_agent("ShaCraft-Launcher-Updater/1") + .redirect(reqwest::redirect::Policy::custom(|attempt| { + if attempt.previous().len() <= 5 && protocol::redirect_allowed(attempt.url()) { + attempt.follow() + } else { + attempt.error("Untrusted update redirect") + } + })) + .build() + .map_err(|_| "Не удалось подготовить соединение для обновлений".into()) +} + +fn response_bytes( + client: &Client, + url: &str, + limit: u64, + progress: impl FnMut(u64), +) -> Result>, String> { + let response = client + .get(url) + .send() + .map_err(|_| "Не удалось связаться с GitHub. Проверьте сеть и повторите проверку.")?; + if response.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(None); + } + if !response.status().is_success() { + return Err(format!( + "GitHub не отдал обновление (HTTP {}). Повторите позже.", + response.status().as_u16() + )); + } + if response.content_length().is_some_and(|size| size > limit) { + return Err("Размер ответа превышает подписанный предел".into()); + } + protocol::read_bounded(response, limit, progress).map(Some) +} + +pub(crate) fn check(app: &AppHandle, state: &UpdaterState) -> Result { + if !state.may_check() { + return Err("Сначала завершите текущее обновление лаунчера".into()); + } + let Some(key) = configured_key() else { + return Ok(state.phase( + app, + Phase::Unconfigured, + Some("Подписанные обновления ещё не настроены для этой сборки.".into()), + )); + }; + state.change(app, |data| { + data.candidate = None; + data.status.phase = Phase::Checking; + data.status.can_retry = false; + data.status.message = None; + data.status.downloaded_bytes = 0; + data.status.total_bytes = None; + data.status.available_version = None; + data.status.release_notes = None; + }); + let client = client()?; + let Some(verified) = protocol::fetch_release( + |url, limit| response_bytes(&client, url, limit, |_| {}), + key, + env!("CARGO_PKG_VERSION"), + )? + else { + return Ok(state.phase(app, Phase::NoUpdate, None)); + }; + let manual = current_mode(app) == PackageMode::Manual; + Ok(state.change(app, |data| { + data.status.available_version = Some(verified.release.version.clone()); + data.status.release_notes = Some(verified.release.notes.clone()); + data.status.phase = if manual { Phase::Manual } else { Phase::Available }; + data.status.message = manual.then(|| "Эта сборка обновляется вручную. Для .deb используйте менеджер пакетов. Автообновление AppImage доступно при запуске исходного файла .AppImage; распакованная копия обновляется вручную.".into()); + data.candidate = Some(verified); + })) +} + +fn select_artifact(release: &VerifiedRelease, mode: &PackageMode) -> Result { + match mode { + PackageMode::Automatic { platform, msi } => { + let artifact = if *msi { + release.release.manual_packages.get("windows-x86_64-msi") + } else { + release.release.platforms.get(*platform) + }; + artifact + .cloned() + .ok_or_else(|| "Нет подписанного пакета для текущей платформы".into()) + } + PackageMode::Manual => { + Err("Эту сборку необходимо обновить вручную через официальный выпуск".into()) + } + } +} + +pub(crate) fn download_install( + app: &AppHandle, + state: &UpdaterState, + directory: &Path, + operations: &LauncherOperations, +) -> Result { + let key = configured_key().ok_or("Подписанные обновления не настроены")?; + let release = state + .inner + .lock() + .unwrap() + .candidate + .clone() + .ok_or("Сначала проверьте доступность обновления")?; + if !release.is_newer_than(env!("CARGO_PKG_VERSION"))? { + return Err("Эта версия уже установлена".into()); + } + let mode = current_mode(app); + let artifact = select_artifact(&release, &mode)?; + // Includes cross-process game lease and lifecycle exclusion; held through restart/handoff. + let guard = UpdateGuard::acquire(directory, operations)?; + state.change(app, |data| { + data.status.phase = Phase::Downloading; + data.status.can_retry = false; + data.status.message = None; + data.status.downloaded_bytes = 0; + data.status.total_bytes = Some(artifact.size); + }); + let client = client()?; + let mut last = Instant::now(); + let bytes = response_bytes(&client, &artifact.url, artifact.size, |count| { + if last.elapsed() >= Duration::from_millis(100) || count == artifact.size { + state.change(app, |data| { + data.status.downloaded_bytes = count; + }); + last = Instant::now(); + } + })? + .ok_or("Сервер не вернул пакет обновления")?; + state.phase(app, Phase::Verifying, None); + protocol::verify_artifact(&bytes, &artifact, key)?; + format::verify(&mode, &bytes)?; + let platform = match mode { + PackageMode::Automatic { platform, .. } => platform, + PackageMode::Manual => unreachable!(), + }; + // The plugin's constructor is private. Its check creates the native installer + // context from a fixed version URL; accept only the signed metadata already read. + let builder = app + .updater_builder() + .pubkey(key) + .target(platform) + .endpoints(vec![release + .pinned_endpoint() + .parse() + .map_err(|_| "Некорректный адрес выпуска")?]) + .map_err(|_| "Не удалось настроить установщик обновления")? + .configure_client(|builder| { + builder + .https_only(true) + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(30)) + .redirect(reqwest_updater::redirect::Policy::custom(|attempt| { + if attempt.previous().len() <= 5 && protocol::redirect_allowed(attempt.url()) { + attempt.follow() + } else { + attempt.error("Untrusted update redirect") + } + })) + }); + let mut update = tauri::async_runtime::block_on( + builder + .build() + .map_err(|_| "Не удалось подготовить установщик")? + .check(), + ) + .map_err(|_| "Не удалось сверить подписанный выпуск с установщиком. Повторите попытку.")? + .ok_or("Подписанный выпуск больше не доступен установщику")?; + if update.raw_json != release.json + || update.version != release.release.version + || update.target != platform + { + return Err( + "Метаданные выпуска изменились. Установка остановлена; проверьте обновления заново." + .into(), + ); + } + // MSI installations stay MSI; the signed descriptor is never supplied by JS. + update.download_url = artifact + .url + .parse() + .map_err(|_| "Некорректный адрес пакета")?; + update.signature = artifact.signature.clone(); + // Update::install does NOT verify bytes itself. Keep this immediately before it. + protocol::install_verified(&bytes, &artifact, key, |verified_bytes| { + guard.begin_install(env!("CARGO_PKG_VERSION"), &release.release.version)?; + state.phase( + app, + Phase::Installing, + Some("Лаунчер перезапустится после установки. Не выключайте компьютер.".into()), + ); + update.install(verified_bytes).map_err(|_| "Установка прервалась. Запись о незавершённом обновлении сохранена; следуйте инструкции восстановления.".to_string()) + })?; + // Windows exits inside plugin install after handing off to NSIS/MSI. There is + // no installer PID API; the persistent lifecycle marker guards the new process. + state.phase( + app, + Phase::Ready, + Some("Обновление установлено. Перезапускаем лаунчер.".into()), + ); + app.restart() +} + +pub(crate) fn open_release_page() -> Result<(), String> { + // Fixed executable/argument structure and URL; no shell or webview-supplied input. + #[cfg(target_os = "linux")] + let mut command = { + let mut command = Command::new("xdg-open"); + command.arg(protocol::RELEASES); + command + }; + #[cfg(target_os = "macos")] + let mut command = { + let mut command = Command::new("open"); + command.arg(protocol::RELEASES); + command + }; + #[cfg(target_os = "windows")] + let mut command = { + let mut command = Command::new("rundll32.exe"); + command.args(["url.dll,FileProtocolHandler", protocol::RELEASES]); + command + }; + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map(|_| ()) + .map_err(|_| "Не удалось открыть страницу официальных выпусков".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tauri::utils::config::BundleType; + #[test] + fn package_formats_never_cross_installers_or_architectures() { + assert_eq!( + package_mode("linux", "x86_64", Some(BundleType::Deb)), + PackageMode::Manual + ); + assert_eq!(package_mode("linux", "x86_64", None), PackageMode::Manual); + assert_eq!( + package_mode("windows", "aarch64", Some(BundleType::Nsis)), + PackageMode::Manual + ); + assert_eq!( + package_mode("windows", "x86_64", Some(BundleType::Msi)), + PackageMode::Automatic { + platform: "windows-x86_64", + msi: true + } + ); + assert_eq!( + package_mode("macos", "aarch64", Some(BundleType::App)), + PackageMode::Automatic { + platform: "darwin-aarch64", + msi: false + } + ); + assert_eq!( + package_mode("macos", "x86_64", Some(BundleType::App)), + PackageMode::Automatic { + platform: "darwin-x86_64", + msi: false + } + ); + } + #[test] + fn appimage_runtime_requires_absolute_ordinary_image_file() { + let absolute = std::env::temp_dir().join("launcher.AppImage"); + let mut header = [0; 20]; + header[..6].copy_from_slice(b"\x7fELF\x02\x01"); + header[8..11].copy_from_slice(b"AI\x02"); + header[18..20].copy_from_slice(b"\x3e\x00"); + assert!(appimage_context_valid(Some(&absolute), true, &header)); + // A stamped extracted binary has no APPIMAGE runtime path. A relative + // path, missing file/directory/symlink or ordinary ELF is also manual. + assert!(!appimage_context_valid(None, true, &header)); + assert!(!appimage_context_valid( + Some(Path::new("launcher.AppImage")), + true, + &header + )); + assert!(!appimage_context_valid(Some(&absolute), false, &header)); + assert!(!appimage_context_valid( + Some(&absolute), + true, + &header[..10] + )); + header[8..11].fill(0); + assert!(!appimage_context_valid(Some(&absolute), true, &header)); + } + + #[test] + fn appimage_file_context_rejects_missing_directory_symlink_and_raw_binary() { + let root = std::env::temp_dir().join(format!( + "shacraft-appimage-context-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + )); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("launcher.AppImage"); + assert!(!appimage_file_ready(None)); + assert!(!appimage_file_ready(Some(&path))); + assert!(!appimage_file_ready(Some(&root))); + let mut header = [0; 20]; + header[..6].copy_from_slice(b"\x7fELF\x02\x01"); + header[18..20].copy_from_slice(b"\x3e\x00"); + std::fs::write(&path, header).unwrap(); + assert!(!appimage_file_ready(Some(&path))); // ordinary extracted ELF + header[8..11].copy_from_slice(b"AI\x02"); + std::fs::write(&path, header).unwrap(); + assert!(appimage_file_ready(Some(&path))); // runtime image header fixture + #[cfg(unix)] + { + let link = root.join("linked.AppImage"); + std::os::unix::fs::symlink(&path, &link).unwrap(); + assert!(!appimage_file_ready(Some(&link))); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn appdir_binding_rejects_inherited_parent_context_and_escaped_executable() { + let root = std::env::temp_dir().join(format!( + "shacraft-appdir-context-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + )); + let appdir = root.join("ShaCraft.AppDir"); + let executable = appdir.join("usr/bin/shacraft-launcher"); + std::fs::create_dir_all(executable.parent().unwrap()).unwrap(); + std::fs::write(&executable, b"fixture").unwrap(); + let parent_appdir = root.join("Other.AppDir"); + std::fs::create_dir(&parent_appdir).unwrap(); + let bare = root.join("shacraft-launcher"); + std::fs::write(&bare, b"fixture").unwrap(); + assert!(appdir_matches_executable(Some(&appdir), Some(&executable))); + assert!(!appdir_matches_executable(None, Some(&executable))); + assert!(!appdir_matches_executable(Some(&appdir), None)); + assert!(!appdir_matches_executable( + Some(Path::new("relative.AppDir")), + Some(&executable) + )); + assert!(!appdir_matches_executable( + Some(&parent_appdir), + Some(&executable) + )); + assert!(!appdir_matches_executable(Some(&appdir), Some(&bare))); + assert!(!appdir_matches_executable(Some(&root), Some(&executable))); // arbitrary ancestor is not sufficient + #[cfg(unix)] + { + std::fs::remove_file(&executable).unwrap(); + std::os::unix::fs::symlink(&bare, &executable).unwrap(); + assert!(!appdir_matches_executable(Some(&appdir), Some(&executable))); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn updater_single_flight_releases_after_failure() { + let state = UpdaterState::default(); + let permit = state.acquire().unwrap(); + assert!(state.acquire().is_err()); + drop(permit); + assert!(state.acquire().is_ok()); + } +} diff --git a/src-tauri/src/updater/format.rs b/src-tauri/src/updater/format.rs new file mode 100644 index 0000000..b476fb8 --- /dev/null +++ b/src-tauri/src/updater/format.rs @@ -0,0 +1,94 @@ +//! Do not let the plugin's byte sniffing select a different Windows installer +//! than the installed package. Metadata, signature and these checks all agree. +use super::PackageMode; + +pub(super) fn verify(mode: &PackageMode, bytes: &[u8]) -> Result<(), String> { + let valid = match mode { + PackageMode::Automatic { + platform: "windows-x86_64", + msi: true, + } => bytes.starts_with(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"), + PackageMode::Automatic { + platform: "windows-x86_64", + msi: false, + } => { + let pe = bytes + .get(0x3c..0x40) + .map(|raw| u32::from_le_bytes(raw.try_into().unwrap()) as usize); + bytes.starts_with(b"MZ") + && pe.and_then(|at| at.checked_add(4).and_then(|end| bytes.get(at..end))) + == Some(b"PE\0\0".as_slice()) + } + PackageMode::Automatic { + platform: "linux-x86_64", + msi: false, + } => { + bytes.starts_with(b"\x7fELF\x02\x01") // ELF64, little-endian + && bytes.get(8..11) == Some(b"AI\x02".as_slice()) // AppImage Type2 magic + && bytes.get(18..20) == Some(b"\x3e\x00".as_slice()) + } // EM_X86_64 + PackageMode::Automatic { + platform: "darwin-aarch64" | "darwin-x86_64", + msi: false, + } => bytes.starts_with(b"\x1f\x8b\x08"), // gzip archive; actual .app architecture is signed in metadata + _ => false, + }; + if valid { + Ok(()) + } else { + Err("Формат подписанного пакета не соответствует установленному лаунчеру. Автоматическая смена типа установки запрещена.".into()) + } +} + +pub(super) fn installed_label() -> &'static str { + use tauri::utils::{config::BundleType, platform::bundle_type}; + if cfg!(debug_assertions) { + return "development"; + } + match bundle_type() { + Some(BundleType::AppImage) => "AppImage", + Some(BundleType::Deb) => "deb", + Some(BundleType::Rpm) => "rpm", + Some(BundleType::Msi) => "MSI", + Some(BundleType::Nsis) => "NSIS", + Some(BundleType::App | BundleType::Dmg) => "app", + None => "unpackaged", + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn mode(platform: &'static str, msi: bool) -> PackageMode { + PackageMode::Automatic { platform, msi } + } + #[test] + fn windows_installers_cannot_silently_switch_formats() { + let msi = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"; + let mut exe = vec![0; 96]; + exe[..2].copy_from_slice(b"MZ"); + exe[0x3c] = 64; + exe[64..68].copy_from_slice(b"PE\0\0"); + assert!(verify(&mode("windows-x86_64", true), msi).is_ok()); + assert!(verify(&mode("windows-x86_64", false), &exe).is_ok()); + assert!(verify(&mode("windows-x86_64", true), &exe).is_err()); + assert!(verify(&mode("windows-x86_64", false), msi).is_err()); + exe[0x3c..0x40].fill(255); // malformed offset cannot panic/wrap + assert!(verify(&mode("windows-x86_64", false), &exe).is_err()); + } + #[test] + fn linux_requires_the_expected_appimage_arch_and_mac_requires_archive() { + let mut image = vec![0; 32]; + image[..6].copy_from_slice(b"\x7fELF\x02\x01"); + image[8..11].copy_from_slice(b"AI\x02"); + image[18..20].copy_from_slice(b"\x3e\x00"); + assert!(verify(&mode("linux-x86_64", false), &image).is_ok()); + image[18] = 183; // ARM64 ELF is never an x86_64 update. + assert!(verify(&mode("linux-x86_64", false), &image).is_err()); + for platform in ["darwin-aarch64", "darwin-x86_64"] { + assert!(verify(&mode(platform, false), b"\x1f\x8b\x08").is_ok()); + assert!(verify(&mode(platform, false), b"MSI").is_err()); + } + assert!(verify(&PackageMode::Manual, b"\x1f\x8b\x08").is_err()); + } +} diff --git a/src-tauri/src/updater/protocol.rs b/src-tauri/src/updater/protocol.rs new file mode 100644 index 0000000..ee2da8f --- /dev/null +++ b/src-tauri/src/updater/protocol.rs @@ -0,0 +1,294 @@ +//! Signed launcher releases are a separate trust domain from the ShaCraft pack. +//! Authenticate the exact metadata bytes before parsing any URLs or versions. +use base64::{engine::general_purpose::STANDARD, Engine}; +use minisign_verify::{PublicKey, Signature}; +use semver::Version; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::{collections::BTreeMap, io::Read}; +use url::Url; + +pub(crate) const RELEASES: &str = "https://github.com/emil28092005/shacraft-launcher/releases"; +pub(crate) const LATEST: &str = + "https://github.com/emil28092005/shacraft-launcher/releases/latest/download/latest.json"; +pub(crate) const MAX_METADATA: u64 = 32768; +pub(crate) const MAX_SIGNATURE: u64 = 2048; +pub(crate) const MAX_PACKAGE: u64 = 1024 * 1024 * 1024; +const PINNED_KEY: &str = include_str!("../../updater-public-key.txt"); +pub(crate) fn test_build() -> bool { + option_env!("SHACRAFT_UPDATER_TEST_BUILD") == Some("1") +} + +pub(crate) fn configured_key() -> Option<&'static str> { + let injected = option_env!("SHACRAFT_UPDATER_PUBLIC_KEY").map(str::trim); + let pinned = PINNED_KEY.trim(); + // Explicit CI-only builds may use a disposable real signing key. Release CI + // forbids this switch; every such binary exposes its test provenance in status. + if test_build() { + return injected.filter(|key| key_is_valid(key)); + } + if key_is_valid(pinned) && injected.is_none_or(|key| key == pinned) { + Some(pinned) + } else { + None + } +} + +const PLATFORMS: [(&str, &str); 4] = [ + ("windows-x86_64", "windows-x86_64-setup.exe"), + ("linux-x86_64", "linux-x86_64.AppImage"), + ("darwin-aarch64", "darwin-aarch64.app.tar.gz"), + ("darwin-x86_64", "darwin-x86_64.app.tar.gz"), +]; +const MANUAL: [(&str, &str); 4] = [ + ("windows-x86_64-msi", "windows-x86_64.msi"), + ("linux-x86_64-deb", "linux-x86_64.deb"), + ("darwin-aarch64-dmg", "darwin-aarch64.dmg"), + ("darwin-x86_64-dmg", "darwin-x86_64.dmg"), +]; + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Artifact { + pub url: String, + pub signature: String, + pub sha256: String, + pub size: u64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct Release { + pub schema_version: u32, + pub version: String, + pub tag: String, + pub notes: String, + #[serde(rename = "pub_date")] + pub pub_date: String, + pub platforms: BTreeMap, + pub manual_packages: BTreeMap, +} + +#[derive(Clone)] +pub(crate) struct VerifiedRelease { + pub release: Release, + pub json: serde_json::Value, +} + +fn decode_text(encoded: &str) -> Result { + let bytes = STANDARD + .decode(encoded.trim()) + .map_err(|_| "Некорректный формат подписи обновления")?; + String::from_utf8(bytes).map_err(|_| "Некорректный формат подписи обновления".into()) +} + +pub(crate) fn key_is_valid(key: &str) -> bool { + key.len() <= MAX_SIGNATURE as usize + && decode_text(key) + .ok() + .and_then(|text| PublicKey::decode(&text).ok()) + .is_some() +} + +pub(crate) fn verify_signature(bytes: &[u8], signature: &str, key: &str) -> Result<(), String> { + if signature.len() > MAX_SIGNATURE as usize || key.len() > MAX_SIGNATURE as usize { + return Err("Некорректный размер подписи обновления".into()); + } + let public = PublicKey::decode(&decode_text(key)?) + .map_err(|_| "Не настроен доверенный ключ обновлений")?; + let signature = Signature::decode(&decode_text(signature)?) + .map_err(|_| "Некорректный формат подписи обновления")?; + // Same minisign primitive/legacy compatibility as tauri-plugin-updater 2.11.0. + public + .verify(bytes, &signature, true) + .map_err(|_| "Подпись обновления не прошла проверку".into()) +} + +fn stable_version(value: &str) -> Result { + let v = Version::parse(value).map_err(|_| "Некорректная версия обновления")?; + if !v.pre.is_empty() + || !v.build.is_empty() + || v.to_string() != value + || v.major > 255 + || v.minor > 255 + || v.patch > 65535 + { + return Err("Разрешены только стабильные версии обновлений".into()); + } + Ok(v) +} + +impl VerifiedRelease { + pub fn parse(bytes: &[u8], signature: &str, key: &str) -> Result { + if bytes.len() as u64 > MAX_METADATA { + return Err("Слишком большой список обновлений".into()); + } + verify_signature(bytes, signature, key)?; + let release: Release = serde_json::from_slice(bytes) + .map_err(|_| "Некорректные подписанные метаданные обновления")?; + stable_version(&release.version)?; + if release.schema_version != 1 + || release.tag != format!("v{}", release.version) + || release.notes.len() > 4096 + || release.pub_date.len() != 20 + || !release.pub_date.ends_with('Z') + { + return Err("Неподдерживаемые метаданные обновления".into()); + } + let date = time::OffsetDateTime::parse( + &release.pub_date, + &time::format_description::well_known::Rfc3339, + ) + .map_err(|_| "Некорректная дата подписанного выпуска")?; + if date + .format(&time::format_description::well_known::Rfc3339) + .map_err(|_| "Некорректная дата выпуска")? + != release.pub_date + { + return Err("Некорректная дата подписанного выпуска".into()); + } + validate_artifacts(&release.platforms, &PLATFORMS, &release)?; + validate_artifacts(&release.manual_packages, &MANUAL, &release)?; + Ok(Self { + release, + json: serde_json::from_slice(bytes).map_err(|_| "Некорректные метаданные")?, + }) + } + + pub fn is_newer_than(&self, current: &str) -> Result { + let available = stable_version(&self.release.version)?; + let installed = stable_version(current)?; + if available < installed { + return Err( + "Сервер предложил более старую версию. Понижение версии заблокировано.".into(), + ); + } + Ok(available > installed) + } + + pub fn pinned_endpoint(&self) -> String { + format!("{RELEASES}/download/{}/latest.json", self.release.tag) + } +} + +fn validate_artifacts( + values: &BTreeMap, + expected: &[(&str, &str)], + release: &Release, +) -> Result<(), String> { + if values.len() != expected.len() { + return Err("Неполный список платформ обновления".into()); + } + for (platform, suffix) in expected { + let artifact = values + .get(*platform) + .ok_or("Отсутствует ожидаемая платформа обновления")?; + let expected_url = format!( + "{RELEASES}/download/{}/shacraft-launcher_{}_{}", + release.tag, release.version, suffix + ); + if artifact.url != expected_url + || artifact.size == 0 + || artifact.size > MAX_PACKAGE + || artifact.sha256.len() != 64 + || !artifact + .sha256 + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || artifact.signature.is_empty() + || artifact.signature.len() > MAX_SIGNATURE as usize + { + return Err("Неверная привязка пакета к версии, платформе или репозиторию".into()); + } + let text = decode_text(&artifact.signature)?; + Signature::decode(&text).map_err(|_| "Некорректная подпись пакета обновления")?; + } + Ok(()) +} + +/// Only these GitHub release hosts may participate in HTTPS redirects. CDN query +/// parameters are GitHub's signed delivery URLs; initial URLs are exact literals. +pub(crate) fn redirect_allowed(url: &Url) -> bool { + if url.scheme() != "https" + || !url.username().is_empty() + || url.password().is_some() + || url.port_or_known_default() != Some(443) + || url.fragment().is_some() + { + return false; + } + match url.host_str() { + Some("github.com") => { + url.query().is_none() + && url + .path() + .starts_with("/emil28092005/shacraft-launcher/releases/") + } + Some("release-assets.githubusercontent.com") => true, + _ => false, + } +} + +pub(crate) fn read_bounded( + mut reader: impl Read, + limit: u64, + mut progress: impl FnMut(u64), +) -> Result, String> { + let mut result = Vec::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = reader + .read(&mut buffer) + .map_err(|_| "Соединение прервалось. Повторите загрузку обновления.")?; + if count == 0 { + break; + } + if result.len() as u64 + count as u64 > limit { + return Err("Размер ответа превышает подписанный предел".into()); + } + result.extend_from_slice(&buffer[..count]); + progress(result.len() as u64); + } + Ok(result) +} + +pub(crate) fn verify_artifact(bytes: &[u8], artifact: &Artifact, key: &str) -> Result<(), String> { + if bytes.len() as u64 != artifact.size + || format!("{:x}", Sha256::digest(bytes)) != artifact.sha256 + { + return Err( + "Размер или SHA-256 пакета обновления не совпал. Установка остановлена.".into(), + ); + } + verify_signature(bytes, &artifact.signature, key) +} + +pub(crate) fn install_verified( + bytes: &[u8], + artifact: &Artifact, + key: &str, + installer: impl FnOnce(&[u8]) -> Result, +) -> Result { + verify_artifact(bytes, artifact, key)?; + installer(bytes) +} + +/// A narrow injectable read boundary; production uses only the fixed HTTPS client. +pub(crate) fn fetch_release( + mut fetch: impl FnMut(&str, u64) -> Result>, String>, + key: &str, + current: &str, +) -> Result, String> { + let Some(bytes) = fetch(LATEST, MAX_METADATA)? else { + return Ok(None); + }; + let signature = fetch(&format!("{LATEST}.sig"), MAX_SIGNATURE)? + .ok_or("Отсутствует подпись списка обновлений")?; + let signature = + std::str::from_utf8(&signature).map_err(|_| "Некорректная подпись списка обновлений")?; + let verified = VerifiedRelease::parse(&bytes, signature, key)?; + Ok(verified.is_newer_than(current)?.then_some(verified)) +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/updater/protocol/tests.rs b/src-tauri/src/updater/protocol/tests.rs new file mode 100644 index 0000000..ebefa50 --- /dev/null +++ b/src-tauri/src/updater/protocol/tests.rs @@ -0,0 +1,204 @@ +use super::*; +use std::{ + cell::Cell, + collections::VecDeque, + io::{self, Cursor}, +}; + +const KEY: &str = include_str!("../../../tests/fixtures/updater/public-key.txt"); +const METADATA: &[u8] = include_bytes!("../../../tests/fixtures/updater/latest.json"); +const SIG: &str = include_str!("../../../tests/fixtures/updater/latest.json.sig"); +const PACKAGE: &[u8] = include_bytes!("../../../tests/fixtures/updater/package.txt"); + +fn release() -> VerifiedRelease { + VerifiedRelease::parse(METADATA, SIG, KEY).unwrap() +} + +#[test] +fn real_tauri_signatures_authenticate_metadata_and_fake_installer_input() { + assert!(key_is_valid(KEY)); + assert!(!key_is_valid("unconfigured")); + let release = release(); + let artifact = &release.release.platforms["linux-x86_64"]; + let calls = Cell::new(0); + install_verified(PACKAGE, artifact, KEY, |bytes| { + calls.set(calls.get() + 1); + assert_eq!(bytes, PACKAGE); + Ok(()) + }) + .unwrap(); + assert_eq!(calls.get(), 1); + // Real signed test data only. This fake installer never executes the fixture. +} + +#[test] +fn tampered_metadata_version_platform_or_package_cannot_reach_installer() { + for (from, to) in [ + ("0.3.0", "0.9.0"), + ("darwin-aarch64", "darwin-mips64"), + ("shacraft-launcher/releases", "other-repository/releases"), + ] { + let changed = String::from_utf8(METADATA.to_vec()) + .unwrap() + .replace(from, to); + assert!(VerifiedRelease::parse(changed.as_bytes(), SIG, KEY).is_err()); + } + let release = release(); + let artifact = &release.release.platforms["linux-x86_64"]; + let calls = Cell::new(0); + let mut corrupt = PACKAGE.to_vec(); + corrupt[0] ^= 1; + assert!(install_verified(&corrupt, artifact, KEY, |_| { + calls.set(1); + Ok(()) + }) + .is_err()); + let mut wrong_signature = artifact.clone(); + wrong_signature.signature = release.release.platforms["windows-x86_64"] + .signature + .clone(); + assert!(install_verified(PACKAGE, &wrong_signature, KEY, |_| { + calls.set(1); + Ok(()) + }) + .is_err()); + assert_eq!(calls.get(), 0); +} + +#[test] +fn same_version_is_no_update_and_downgrades_or_prereleases_are_rejected() { + let release = release(); + assert!(release.is_newer_than("0.2.0").unwrap()); + assert!(!release.is_newer_than("0.3.0").unwrap()); + assert!(release.is_newer_than("0.4.0").is_err()); + for bad in [ + "v0.3.0", + "0.3.0-rc.1", + "0.3.0+other", + "01.2.3", + "256.0.0", + "0.256.0", + "0.0.65536", + ] { + assert!(stable_version(bad).is_err(), "{bad}"); + } +} + +#[test] +fn all_platform_descriptors_bind_repository_tag_filename_size_and_hash() { + let release = release(); + let mut missing = release.release.platforms.clone(); + missing.remove("darwin-aarch64"); + assert!(validate_artifacts(&missing, &PLATFORMS, &release.release).is_err()); + let mut wrong_arch = release.release.platforms.clone(); + wrong_arch.insert( + "darwin-aarch64".into(), + release.release.platforms["darwin-x86_64"].clone(), + ); + assert!(validate_artifacts(&wrong_arch, &PLATFORMS, &release.release).is_err()); + for url in [ + "https://github.com/other/repo/releases/download/v0.3.0/file", + "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.2.0/file", + "https://evil.invalid/update", + ] { + let mut wrong = release.release.platforms.clone(); + wrong.get_mut("linux-x86_64").unwrap().url = url.into(); + assert!(validate_artifacts(&wrong, &PLATFORMS, &release.release).is_err()); + } + for size in [0, MAX_PACKAGE + 1] { + let mut wrong = release.release.platforms.clone(); + wrong.get_mut("linux-x86_64").unwrap().size = size; + assert!(validate_artifacts(&wrong, &PLATFORMS, &release.release).is_err()); + } + let mut wrong = release.release.platforms.clone(); + wrong.get_mut("linux-x86_64").unwrap().sha256 = "A".repeat(64); + assert!(validate_artifacts(&wrong, &PLATFORMS, &release.release).is_err()); +} + +#[test] +fn github_redirects_cannot_leave_release_hosts_or_downgrade_https() { + for url in [ + LATEST, + "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/latest.json", + "https://release-assets.githubusercontent.com/github-production-release-asset/123?sig=test", + ] { + assert!(redirect_allowed(&Url::parse(url).unwrap()), "{url}"); + } + for url in [ + "http://github.com/emil28092005/shacraft-launcher/releases", + "https://github.com/other/repository/releases/latest", + "https://release-assets.githubusercontent.com.evil.invalid/file", + "https://evil.invalid/file", + "https://user@github.com/emil28092005/shacraft-launcher/releases/a", + "https://github.com:8443/emil28092005/shacraft-launcher/releases/a", + "https://github.com/emil28092005/shacraft-launcher/releases/a#extra", + ] { + assert!(!redirect_allowed(&Url::parse(url).unwrap()), "{url}"); + } +} + +#[test] +fn bounded_reads_reject_oversized_or_interrupted_downloads() { + assert!(read_bounded(Cursor::new(b"12345"), 4, |_| {}).is_err()); + assert_eq!( + read_bounded(Cursor::new(b"1234"), 4, |_| {}).unwrap(), + b"1234" + ); + struct Broken; + impl Read for Broken { + fn read(&mut self, _: &mut [u8]) -> io::Result { + Err(io::Error::other("test disconnect")) + } + } + assert!(read_bounded(Broken, 10, |_| {}).is_err()); +} + +#[test] +fn network_failure_and_bad_signature_retry_fetch_whole_signed_release() { + let mut replies = VecDeque::from([ + Err("offline".into()), + Ok(Some(METADATA.to_vec())), + Ok(Some(b"bad-signature".to_vec())), + Ok(Some(METADATA.to_vec())), + Ok(Some(SIG.as_bytes().to_vec())), + ]); + let mut urls = Vec::new(); + let mut fetch = |url: &str, _| { + urls.push(url.to_string()); + replies.pop_front().unwrap() + }; + assert!(fetch_release(&mut fetch, KEY, "0.2.0").is_err()); + assert!(fetch_release(&mut fetch, KEY, "0.2.0").is_err()); + assert_eq!( + fetch_release(&mut fetch, KEY, "0.2.0") + .unwrap() + .unwrap() + .release + .version, + "0.3.0" + ); + assert_eq!( + urls, + [ + LATEST, + LATEST, + &format!("{LATEST}.sig"), + LATEST, + &format!("{LATEST}.sig") + ] + ); + assert!(fetch_release(|_, _| Ok(None), KEY, "0.2.0") + .unwrap() + .is_none()); +} + +#[test] +fn installer_failure_is_reported_and_a_fresh_attempt_reverifies_input() { + let release = release(); + let artifact = &release.release.platforms["linux-x86_64"]; + assert!(install_verified(PACKAGE, artifact, KEY, |_| Err::<(), _>( + "simulated installer failure".into() + )) + .is_err()); + assert!(install_verified(PACKAGE, artifact, KEY, |_| Ok(())).is_ok()); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bffddb0..1ba8231 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ShaCraft Launcher", - "version": "0.1.1", + "version": "0.2.0", "identifier": "ru.shacraft.launcher", "build": { "beforeDevCommand": "npm run dev", @@ -35,6 +35,22 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "windows": { + "wix": { + "upgradeCode": "2058b1df-56a1-51ef-bd48-d296479cd59a" + }, + "nsis": { + "installMode": "currentUser" + } + } + }, + "plugins": { + "updater": { + "pubkey": "", + "windows": { + "installMode": "passive" + } + } } } diff --git a/src-tauri/tests/fixtures/updater/latest.json b/src-tauri/tests/fixtures/updater/latest.json new file mode 100644 index 0000000..b183575 --- /dev/null +++ b/src-tauri/tests/fixtures/updater/latest.json @@ -0,0 +1,59 @@ +{ + "manualPackages": { + "darwin-aarch64-dmg": { + "sha256": "409ed0537cccf2724f405fd0031f6e84718442b263b75513532e03ecc991a678", + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUlYSjNaNG55a084WTlCTFlhQTdOdlNNZ3JYYjNaeXZLOThXWERjN3kwWkRtcnNCN0NMVDJBOEpUdVVlS2JJYkpZc3dkdWtyOXQvQ2k3dGJ1bldyRXc4PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLWFhcmNoNjQuZG1nCnRnRlh2dnFUdnJ1MG4vTTF6SExvNzk3ZVpvUkZ5Nk0vdjRiSFpiaHFZUkw5a3N4TkkyODV3RDY0MmJyTXpNdFUwL1NBSlluTG5WeVlMdE1xa3pkWUNnPT0K", + "size": 97, + "url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-aarch64.dmg" + }, + "darwin-x86_64-dmg": { + "sha256": "70496d49bf410231a2575c384a306023ab5eeaf312d4dd93b6e8aed3e2ee9492", + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUJ2biswTzY5QmNNU2hmRHVRV2xpUEtBay94SW1NdHArVnJDQmhLZ3N4UEVQSkxJbG92dmF1bSt4RmJkSm0wOExzNlRDUHJJaVpEUWZvVGoxd0NkNFFrPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLXg4Nl82NC5kbWcKVEFaRkxCUFlSRUQ1T0NOa0doclVucnVLNWw0STcxV05ybkxvcWNrazlPdGVEdU5FTlVlMTFSNFlTUEV1RUNvREZqTFZ0UlN3MTJRcGttQWpxblZUQUE9PQo=", + "size": 96, + "url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-x86_64.dmg" + }, + "linux-x86_64-deb": { + "sha256": "cefbadc09d008ff4b0f26ee788f524644b7dadf9de4549bd8d841e44ad92e292", + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheU9BMWNrS1hCWHdJWlNFczFJNklxMDJsdHMwZ1NFakpxTDExS0xFUXVwWVFRTVh3Q2Vpc0MvUEI4NXZmTjJjMnZaRFoydVk1SHdMSkkwNFRqMkZIUUFFPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfbGludXgteDg2XzY0LmRlYgpjMVZYeVJuUEZWWithQ2lFVHdkWXBqMDA4NGlKT3FYL3djclhyK1hTSE1RVzVrN0p6TVV1V3plOFlKZHQ5K1pzWDMzcmtPYWFPaCtZeEE4WWt6cW5EZz09Cg==", + "size": 95, + "url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_linux-x86_64.deb" + }, + "windows-x86_64-msi": { + "sha256": "fac4bdeb3c95d4c0a91b5f03215a0dd8f3e676b7e7f9140eef1616d2fefa5c4b", + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUdYODluWEtUd2RGNUVyRzNTQWpNV1VoOXpFaXVCZ05SbUpwQTQzYnU2WmI0bzIxVTBTdXFNN2p0Z3NYRndhNmlqTGFzRkZiYllhb0pzbFN4UkMrUUFrPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfd2luZG93cy14ODZfNjQubXNpClZxRlZTL2NobmcreDBLVnc5QzdyZHRkS01NVVZOY2ZpcDNBTWcvUGt6dm15bUNTUzEra09RRDk1Z1VWbmwyU1NjblFXQ0NCcXhTUlpnUEpocWpqQkRRPT0K", + "size": 97, + "url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_windows-x86_64.msi" + } + }, + "notes": "Synthetic test metadata.", + "platforms": { + "darwin-aarch64": { + "sha256": "73f3eea52be58fb872d2f6423f5cc604b1599403a556a2208173f8b3ea1e66cb", + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheVBPN2xlbzEyQ3h2SkRCdzNlUnFxVEhzQjNYRGNNT1B2enc5TUxKQ1U5TDdwaGhkOUxqNTRnZ3FheDN6UE82SWpMR0YvQUZQR255bWRZcTdYYy9qMWdjPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLWFhcmNoNjQuYXBwLnRhci5negp5ckVPVnhvSE1yNitYZm9SRi9KRVNjTWhMVUY3dnNDYmViSHNSWmE2TlFpOVJqd2RxZVJuY0NqVHd6UU5PYUt2NXVQN1JKSHlhZVdiY3JvTjdLcUZCQT09Cg==", + "size": 104, + "url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-aarch64.app.tar.gz" + }, + "darwin-x86_64": { + "sha256": "548d2042689d797dbcb29f09d206e1c36814880a831a26945bf0c550f2617a64", + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheVB5MVRuUU52OEdLZDJHYWhmMUtWaDNQMnM0U0xCcC9sdkE0TWhSK0svNzRuTVBtVW5NUTh6MEQ5amVKd0Vpa1l4cWZ2YzNtWW41Y0RQV0VrTitUbVF3PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLXg4Nl82NC5hcHAudGFyLmd6ClFuY3IwOXZKaWsrUTIwMGZmTFdFZmh2K3ZEcDAzYXBpSi8wVnJNS05Zb25YdVgyd1IzLzZud1JqaFNHdXZwa0FpclEyQnplZTFjR1RqNUpzQm9qdUJnPT0K", + "size": 103, + "url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-x86_64.app.tar.gz" + }, + "linux-x86_64": { + "sha256": "150e75098117b452b56bfd3925e2c6af03129b47e17be4529f4498b17a9ce9ae", + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUh1Znd0d2k0c3VGd085c3N2RWVmZnliem04Tzc4MjBaOE1Sd1NWdU9pNU9zZjlMZWNoRnBxaDN6eGlER0hxTDVyRUtVdXdjOUhWK29oM1hSSC84Qmc4PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfbGludXgteDg2XzY0LkFwcEltYWdlCmdFZTNDdmt0ckRySnB3NWtBeUdYV3drSVZidmNKZEpsOGptcERGK0VjQmpUUDU0c1ZWV1diTWVTZTlhRzVnUVJFRlVITVNhVGxrbS8rMkdTREtUQUNBPT0K", + "size": 100, + "url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_linux-x86_64.AppImage" + }, + "windows-x86_64": { + "sha256": "a9773409a6545e98af6a9aad2de2b7d89be257c33af6566bd7ffefc1f443d1be", + "signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUduVjJYSS83QzVIMXRBK0ZabHk1UGxZMUxkNVkzYXE4VXgranlqRnluSlhOdlBBWU5qaTB5MmQySTZ6M3pZMmZHNHI4dkZpRzk2Q2NXWS9GbXJ2N3dVPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfd2luZG93cy14ODZfNjQtc2V0dXAuZXhlCmlneXpmSGc0WlM3STgxTHUzbldxdVpqbjhraUp6K1c3djhQSWdmOHh0WTFwMnB2cmIwMHdjMVVOajJKVWR0MTVwTTBYd0NvYUk1OVVuemRUakh3SkJBPT0K", + "size": 103, + "url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_windows-x86_64-setup.exe" + } + }, + "pub_date": "2026-09-09T00:00:00Z", + "schemaVersion": 1, + "tag": "v0.3.0", + "version": "0.3.0" +} diff --git a/src-tauri/tests/fixtures/updater/latest.json.sig b/src-tauri/tests/fixtures/updater/latest.json.sig new file mode 100644 index 0000000..e2ae893 --- /dev/null +++ b/src-tauri/tests/fixtures/updater/latest.json.sig @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUVnVElINVRoVDVpb2VGV21XT0wzYXN1WmxxTEhGZHVvYzg3S1FVcUFPVnVqa3FpRXFWbFl2T2FHN2ZmOU1FRVpQR1krREhCcU00RVpXMXYzd2s5UndnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIxCWZpbGU6bGF0ZXN0Lmpzb24KVkxhbGNUZE9NSUpTWlR3L1hDczFTTkFzbjkrK1ZyWE9EeG5xTUVBUnRYcW1kZHphNXVTTEs4OTU0WitORWdQdk9PaXNhVWVvbTFJRHFBeWRtQmdzQXc9PQo= \ No newline at end of file diff --git a/src-tauri/tests/fixtures/updater/package.txt b/src-tauri/tests/fixtures/updater/package.txt new file mode 100644 index 0000000..165edd4 --- /dev/null +++ b/src-tauri/tests/fixtures/updater/package.txt @@ -0,0 +1,2 @@ +ShaCraft synthetic fixture; never execute or install. +shacraft-launcher_0.3.0_linux-x86_64.AppImage diff --git a/src-tauri/tests/fixtures/updater/public-key.txt b/src-tauri/tests/fixtures/updater/public-key.txt new file mode 100644 index 0000000..6a90d79 --- /dev/null +++ b/src-tauri/tests/fixtures/updater/public-key.txt @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4NUEyNTNEQjlERDZDMkIKUldRcmJOMjVQU1ZheUQ2UzVTN0NHS21ydFp1c1REajVucjlXYnFPK3ZSYWYrQVFDaUgvL3lpQ2UK \ No newline at end of file diff --git a/src-tauri/updater-public-key.txt b/src-tauri/updater-public-key.txt new file mode 100644 index 0000000..7d77859 --- /dev/null +++ b/src-tauri/updater-public-key.txt @@ -0,0 +1 @@ +dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIyODAzRjlGOUFFNDM4QzYKUldUR09PU2FueitBSWhoU1Y4S2VFK21OeWRmamVkMlBreWRjbWdtRmNtbEZrTzMwNE92MDU2d3YK \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index da97d00..d12df35 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { useCallback, useState } from 'react' +import { LegacyModsDialog } from './components/LegacyModsDialog' import { Library } from './components/Library' import { RecoveryCodesModal } from './components/RecoveryCodesModal' import { PlayDock } from './components/PlayDock' @@ -10,6 +11,7 @@ import { useAccount } from './hooks/useAccount' import { useLauncher } from './hooks/useLauncher' import { useServerStatus } from './hooks/useServerStatus' import { useSettings } from './hooks/useSettings' +import { useUpdater } from './hooks/useUpdater' import { isNative } from './services/native' import { launchAccess } from './state/account' import { installStageLabels } from './state/game' @@ -17,6 +19,7 @@ import { installStageLabels } from './state/game' export function App() { const [selected, setSelected] = useState(servers[0]) const [settingsOpen, setSettingsOpen] = useState(false) + const [legacyOpen, setLegacyOpen] = useState(false) const [windowError, setWindowError] = useState(null) const preferences = useSettings() const session = useAccount() @@ -25,19 +28,32 @@ export function App() { const closeSettings = useCallback(() => setSettingsOpen(false), []) const desktop = isNative() const profile = launcher.profiles[selected.profileId] + const metadata = launcher.metadata[selected.profileId] + const displayed = { ...selected, version: metadata ? `Minecraft ${metadata.minecraftVersion}` : 'Версия уточняется', + loader: metadata ? `${metadata.loaderKind} ${metadata.loaderVersion}` : 'По подписанной сборке' } const ready = profile?.inspection?.upToDate === true const operation = launcher.game.operation const busy = operation.phase !== 'idle' const access = launchAccess(session.account) const checking = desktop && (!profile || profile.status === 'checking') const settingsBlocked = !preferences.loaded || preferences.saving || !!preferences.error - const disabled = !desktop || busy || session.busy || access === 'loading' || + const updaterBlocked = busy ? 'Завершите игру или дождитесь окончания работы со сборкой.' + : settingsBlocked ? 'Дождитесь сохранения настроек; при ошибке повторите сохранение.' + : session.busy || access === 'loading' ? 'Дождитесь завершения работы с аккаунтом.' + : session.linking ? 'Завершите подтверждение игрового ника перед обновлением лаунчера.' + : session.recoveryCodes.length ? 'Сначала сохраните коды восстановления аккаунта.' + : legacyOpen ? 'Закройте проверку старых модов перед обновлением лаунчера.' : null + const updater = useUpdater(updaterBlocked) + const updaterRecovery = updater.state.status?.phase === 'error' && !updater.state.status.canRetry + const updateLocked = updater.mutating || updater.state.status?.phase === 'ready' || updaterRecovery + const disabled = !desktop || busy || updateLocked || session.busy || access === 'loading' || (access === 'ready' && (checking || settingsBlocked || !launcher.eventsReady)) - const repairDisabled = !desktop || busy || checking + const repairDisabled = !desktop || busy || updateLocked || checking const error = launcher.game.error ?? preferences.error ?? windowError ?? launcher.environmentError ?? session.error ?? profile?.error ?? null let label = 'Играть' if (!desktop) label = 'В приложении' + else if (updateLocked) label = updaterRecovery ? 'Нужно восстановить лаунчер' : 'Обновление лаунчера' else if (operation.phase === 'running') label = 'Игра запущена' else if (operation.phase === 'launching') label = 'Запускаем…' else if (operation.phase === 'installing') label = operation.progress ? `${installStageLabels[operation.progress.stage]}…` : 'Подготовка…' @@ -50,6 +66,11 @@ export function App() { else if (checking) label = 'Проверяем…' else if (!ready) label = 'Проверить' + const onboard = async (nickname: string) => { + if (busy || updateLocked || settingsBlocked || !launcher.eventsReady) return + const result = await launcher.onboard(selected.profileId, nickname) + if (result?.onboarding) session.acceptChallenge(result.onboarding) + } const primary = () => { if (disabled) return if (access !== 'ready') setSettingsOpen(true) @@ -59,19 +80,27 @@ export function App() { return (
-
0}> +
0 || legacyOpen}> setSettingsOpen(true)} /> - - setSettingsOpen(true)} /> + + {(updater.state.status?.phase === 'available' || (updater.state.status?.phase === 'manual' && updater.state.status.availableVersion) || updater.state.status?.phase === 'ready' || updater.mutating || updaterRecovery) && + } + { if (!repairDisabled) void launcher.repair(selected.profileId) }} /> + onLegacy={() => setLegacyOpen(true)} onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
- + + {legacyOpen && setLegacyOpen(false)} onChanged={() => { void launcher.refreshProfile(selected.profileId) }} />}
) diff --git a/src/components/AccountSettings.tsx b/src/components/AccountSettings.tsx index aad6ce9..90c9aac 100644 --- a/src/components/AccountSettings.tsx +++ b/src/components/AccountSettings.tsx @@ -3,7 +3,7 @@ import { LogOut, Users } from 'lucide-react' import type { useAccount } from '../hooks/useAccount' import { isNative } from '../services/native' -export function AccountSettings({ session, locked }: { session: ReturnType; locked: boolean }) { +export function AccountSettings({ session, locked, onOnboard }: { session: ReturnType; locked: boolean; onOnboard?: (nickname: string) => Promise }) { const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [registering, setRegistering] = useState(false) @@ -49,11 +49,17 @@ export function AccountSettings({ session, locked }: { session: ReturnTypeПодтвердите владение ником на сервере Aeronautics. + {onOnboard && } +

Нужна действующая проходка и ник в whitelist. Новый ник: /register, существующий: /login с игровым паролем. После входа выполните команду подтверждения.

)} - {account && linkedNickname &&
Игровой ник{linkedNickname}
} + {account && linkedNickname && <> +
Игровой ник{linkedNickname}
+ + } {session.linkMessage &&

{session.linkMessage}

} {session.error &&

{session.error}

} {account && } diff --git a/src/components/LauncherUpdate.tsx b/src/components/LauncherUpdate.tsx new file mode 100644 index 0000000..751462f --- /dev/null +++ b/src/components/LauncherUpdate.tsx @@ -0,0 +1,76 @@ +import { Download, RefreshCw } from 'lucide-react' +import { canRunUpdater, updaterPercent } from '../state/updater' +import type { UpdaterState } from '../state/updater' + +export interface LauncherUpdateProps { + state: UpdaterState + native: boolean + installedVersion?: string + onCheck: () => void + onInstall: () => void + onRestart: () => void + onOpenRelease: () => void +} + +export function LauncherUpdate({ state, native, installedVersion, onCheck, onInstall, onRestart, onOpenRelease }: LauncherUpdateProps) { + const status = state.status + const phase = status?.phase + const percent = updaterPercent(status) + const downloadedBytes = status?.downloadedBytes ?? 0 + const error = state.error ?? (phase === 'error' ? status?.message : null) + const needsRecovery = phase === 'error' && status?.canRetry === false + const checking = state.pending?.command === 'check' || phase === 'checking' || state.pending?.command === 'status' + const downloading = phase === 'downloading' + const progressing = downloading || phase === 'verifying' || phase === 'installing' || state.pending?.command === 'install' + const packageFormat = status?.packageFormat === 'development' ? 'для разработки' + : status?.packageFormat === 'unpackaged' ? 'без установщика' : status?.packageFormat + const titles = { + idle: 'Можно проверить новую версию', checking: 'Проверяем обновления…', + available: 'Доступно обновление', downloading: 'Скачиваем обновление…', + verifying: 'Проверяем подпись обновления…', installing: 'Устанавливаем обновление…', + ready: 'Обновление установлено — нужен перезапуск', no_update: 'Установлена актуальная версия', + unconfigured: 'Автообновление пока не настроено', manual: 'Обновление пакета вручную', error: 'Не удалось обновить лаунчер', + } + return ( +
+

Лаунчер

+ Версия {status?.installedVersion ?? installedVersion ?? 'уточняется'}{packageFormat && ` · ${packageFormat}`}
+ {status?.testBuild &&

Тестовая сборка · тестовый канал обновлений

} +

{!native ? 'Обновления доступны в приложении лаунчера.' + : phase ? titles[phase] : checking ? 'Читаем состояние обновления…' : 'Проверьте доступные обновления.'}

+ {status?.availableVersion &&

Новая версия: {status.availableVersion}

} + {phase === 'manual' &&

{status?.packageFormat === 'deb' + ? 'Этот пакет обновляется вручную. Для установки .deb используйте системный менеджер пакетов; версия и инструкция доступны на странице выпусков.' + : 'Эта сборка обновляется вручную. Выберите пакет для своей системы на странице выпусков.'}

} + {phase === 'unconfigured' &&

Для этой сборки канал обновлений недоступен. Проверка не меняет установленный лаунчер.

} + {phase === 'available' &&

Установка закроет и перезапустит лаунчер. Игру потребуется завершить, изменения настроек — сохранить.

} + {phase === 'ready' &&

Чтобы продолжить работу в новой версии, перезапустите лаунчер.

} + {needsRecovery &&

Автоматическое продолжение недоступно. Восстановите лаунчер из пакета на странице выпусков.

} + {status?.message && phase !== 'error' &&

{status.message}

} + {status?.releaseNotes &&
Что изменилось

{status.releaseNotes}

} + {progressing &&
+ +
} + {downloading &&

{percent === null + ? `Скачано: ${(Math.max(0, Number.isFinite(downloadedBytes) ? downloadedBytes : 0) / 1024 / 1024).toFixed(1)} МБ` + : `Скачано: ${percent}%`}

} + {error &&

{error}

} + {state.blockedReason && native &&

{state.blockedReason}

} +
+ {phase !== 'ready' && !needsRecovery && } + {phase === 'available' && } + {phase === 'ready' && } + {(phase === 'manual' || needsRecovery) && } +
+
+ ) +} diff --git a/src/components/LegacyModsDialog.tsx b/src/components/LegacyModsDialog.tsx new file mode 100644 index 0000000..be1044d --- /dev/null +++ b/src/components/LegacyModsDialog.tsx @@ -0,0 +1,96 @@ +import { useEffect, useRef, useState } from 'react' +import { native } from '../services/native' +import { errorMessage } from '../services/async' +import type { LegacyMod } from '../types/launcher' + +export function LegacyModsDialog({ profileId, onClose, onChanged }: { profileId: string; onClose: () => void; onChanged: () => void }) { + const [files, setFiles] = useState([]) + const [selected, setSelected] = useState>(new Set()) + const [busy, setBusy] = useState(true) + const [moving, setMoving] = useState(false) + const [error, setError] = useState(null) + const [backup, setBackup] = useState(null) + const dialog = useRef(null) + const closeButton = useRef(null) + const request = useRef(0) + const moveInFlight = useRef(false) + const mounted = useRef(false) + + useEffect(() => { + mounted.current = true + const previous = document.activeElement + closeButton.current?.focus() + return () => { + mounted.current = false + if (previous instanceof HTMLElement && previous.isConnected) previous.focus() + } + }, []) + + useEffect(() => { + const current = ++request.current + setFiles([]); setSelected(new Set()); setBackup(null); setError(null); setBusy(true) + void native.legacyMods(profileId).then((value) => { if (request.current === current) setFiles(value) }) + .catch((reason) => { if (request.current === current) setError(errorMessage(reason, 'Не удалось проверить моды')) }) + .finally(() => { if (request.current === current) setBusy(false) }) + return () => { request.current++ } + }, [profileId]) + + const move = async () => { + if (busy || moveInFlight.current || !selected.size) return + const current = request.current + moveInFlight.current = true + setBusy(true); setMoving(true); setError(null) + dialog.current?.focus() + try { + const result = await native.backupLegacyMods(profileId, files.filter((f) => selected.has(f.path)).map(({path,sha256}) => ({path,sha256}))) + moveInFlight.current = false + if (mounted.current) setMoving(false) + if (request.current !== current) return + setBackup(result.backupRoot); setSelected(new Set()) + onChanged() + const remaining = await native.legacyMods(profileId) + if (request.current === current) setFiles(remaining) + } catch (reason) { + if (request.current === current) setError(errorMessage(reason, 'Не удалось перенести выбранные моды')) + } finally { + moveInFlight.current = false + if (mounted.current) setMoving(false) + if (request.current === current) setBusy(false) + } + } + + return
{ + if (event.key === 'Escape') { + event.preventDefault(); event.stopPropagation() + if (!moveInFlight.current) onClose() + } + if (event.key !== 'Tab') return + const elements = dialog.current?.querySelectorAll('button:not(:disabled), input:not(:disabled)') + const first = elements?.[0] + const last = elements?.[elements.length - 1] + if (!first) { event.preventDefault(); dialog.current?.focus() } + else if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog.current)) { + event.preventDefault(); last?.focus() + } else if (!event.shiftKey && (document.activeElement === last || document.activeElement === dialog.current)) { + event.preventDefault(); first.focus() + } + }}> +

Проверка старых и изменённых модов

+

Эти файлы сохранены: их происхождение или содержимое отличается от ожидаемого. Здесь могут быть ваши моды. Выберите только те, которые хотите убрать из сборки в резервную копию. По умолчанию ничего не выбрано.

+ {busy &&

{moving ? 'Переносим выбранные файлы в резервную копию…' : 'Проверяем файлы…'}

} + {!busy && files.length === 0 &&

Файлов для ручного разбора нет.

} +
{files.map((file) => )}
+ {backup &&

Копии сохранены: {backup}. Теперь можно повторить проверку сборки.

} + {error &&

{error}

} +
+
+
+} diff --git a/src/components/PlayDock.tsx b/src/components/PlayDock.tsx index 4d899e7..f168056 100644 --- a/src/components/PlayDock.tsx +++ b/src/components/PlayDock.tsx @@ -18,6 +18,7 @@ interface PlayDockProps { repairDisabled: boolean onPrimary: () => void onRepair: () => void + onLegacy: () => void } export function PlayDock(props: PlayDockProps) { @@ -41,7 +42,9 @@ export function PlayDock(props: PlayDockProps) { } else { title = needsLogin ? 'Нужен вход ShaCraft' : props.needsLink ? 'Нужно привязать ник' : profile?.status === 'checking' ? 'Проверяем сборку' : profile?.inspection?.upToDate ? 'Сборка готова' : 'Требуется проверка' - detail = profile?.inspection ? `${profile.inspection.managedFiles} файлов под контролем` : 'Проверяем локальные файлы' + detail = profile?.inspection + ? `${profile.inspection.upToDate ? 'Проверено файлов сборки' : 'Файлов в сборке'}: ${profile.inspection.managedFiles}` + : 'Проверяем локальные файлы' } return ( @@ -58,7 +61,8 @@ export function PlayDock(props: PlayDockProps) { {server.version} {memoryGb} ГБ памяти
- + + diff --git a/src/components/ServerStage.tsx b/src/components/ServerStage.tsx index ebfc846..fbb1aca 100644 --- a/src/components/ServerStage.tsx +++ b/src/components/ServerStage.tsx @@ -3,7 +3,7 @@ import { Users } from 'lucide-react' import { isNative } from '../services/native' import type { Server, ServerStatus } from '../types/launcher' -export function ServerStage({ server, status, children }: { server: Server; status: ServerStatus | null; children: ReactNode }) { +export function ServerStage({ server, status, javaMajor, children }: { server: Server; status: ServerStatus | null; javaMajor?: number; children: ReactNode }) { const online = status?.reachable && status.online !== null && status.max !== null ? `${status.online} / ${status.max}` : !isNative() ? 'В приложении' : status === null ? 'Проверяем…' : 'Нет связи' const label = !isNative() ? 'Статус в приложении' : status === null ? 'Проверяем сервер' : status.reachable ? 'Сервер доступен' : 'Сервер недоступен' @@ -17,7 +17,7 @@ export function ServerStage({ server, status, children }: { server: Server; stat

{server.kicker}

{server.name}

{server.subtitle}

Загрузчик
{server.loader}
-
Java
Версия 21
+
Java
{javaMajor ? `Версия ${javaMajor}` : 'По подписанной сборке'}
{children} diff --git a/src/components/SettingsDrawer.tsx b/src/components/SettingsDrawer.tsx index 04bce7a..e84253f 100644 --- a/src/components/SettingsDrawer.tsx +++ b/src/components/SettingsDrawer.tsx @@ -1,6 +1,8 @@ import { useEffect, useRef } from 'react' import { FolderOpen, Wrench, X } from 'lucide-react' import { AccountSettings } from './AccountSettings' +import { LauncherUpdate } from './LauncherUpdate' +import type { useUpdater } from '../hooks/useUpdater' import type { useAccount } from '../hooks/useAccount' import type { useSettings } from '../hooks/useSettings' import type { JavaInstallation, NativeHost } from '../types/launcher' @@ -13,9 +15,13 @@ interface SettingsDrawerProps { preferences: ReturnType session: ReturnType onClose: () => void + requiredJava?: number + onOnboard?: (nickname: string) => Promise + updater: ReturnType + native: boolean } -export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose }: SettingsDrawerProps) { +export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose, requiredJava, onOnboard, updater, native }: SettingsDrawerProps) { const { settings, loaded, saving, error } = preferences const closeButton = useRef(null) useEffect(() => { @@ -25,7 +31,9 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session, const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose() if (event.key !== 'Tab') return - const elements = closeButton.current?.closest('aside')?.querySelectorAll('button:not(:disabled), input:not(:disabled), select:not(:disabled)') + const elements = Array.from(closeButton.current?.closest('aside')?.querySelectorAll( + 'button:not(:disabled), input:not(:disabled), select:not(:disabled), summary, [tabindex="0"]', + ) ?? []).filter((element) => element.getClientRects().length > 0) const first = elements?.[0] const last = elements?.[elements.length - 1] if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() } @@ -47,25 +55,28 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,

Настройки

Игра

+ - +
Папка игры{host ? 'В каталоге лаунчера' : 'Определяется…'}
Java {java === undefined ? host ? 'Проверяем…' : 'Проверяется в приложении' - : java?.major === 21 ? 'Java 21 найдена' : java ? `Нужна Java 21 · найдена ${java.major}` - : 'Лаунчер установит Java 21 автоматически'} + : requiredJava === undefined ? 'Требование Java уточняется по сборке' + : java?.major === requiredJava ? `Java ${requiredJava} найдена` : java ? `Нужна Java ${requiredJava} · найдена ${java.major}` + : `Лаунчер установит Java ${requiredJava} автоматически`}
{error && <>

{error}

} {saving &&

Сохраняем настройки…

}
-
{host ? `Данные лаунчера: ${host.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'}
+
{host ? `Данные лаунчера: ${host.dataDir}` : 'Java будет подобрана по подписанной сборке.'}
) diff --git a/src/components/account.test.tsx b/src/components/account.test.tsx index 525c0ff..5dd8626 100644 --- a/src/components/account.test.tsx +++ b/src/components/account.test.tsx @@ -3,6 +3,10 @@ import { test } from 'node:test' import { renderToStaticMarkup } from 'react-dom/server' import { AccountSettings } from './AccountSettings' import { RecoveryCodesModal } from './RecoveryCodesModal' +import { LegacyModsDialog } from './LegacyModsDialog' +import { PlayDock } from './PlayDock' +import { launchAccess } from '../state/account' +import { servers } from '../data/servers' import type { useAccount } from '../hooks/useAccount' function session(): ReturnType { @@ -10,6 +14,7 @@ function session(): ReturnType { account: null, error: null, busy: false, recoveryCodes: [], linkMessage: null, linking: false, linkedNickname: null, authenticate: async () => true, logout: async () => {}, startLink: async () => {}, clearError: () => {}, + acceptChallenge: () => {}, acknowledgeRecoveryCodes: () => {}, } } @@ -42,3 +47,50 @@ test('recovery codes render only until explicitly acknowledged', () => { match(html, /Я сохранил коды/) equal(renderToStaticMarkup( {}} />), '') }) + +test('onboarding is visible only for a signed-in unlinked account and needs an explicit nickname', () => { + const onOnboard = async () => {} + const unlinked = { username: 'website_login', links: [] } + const html = renderToStaticMarkup() + match(html, /Установить и войти для подтверждения/) + match(html, /]*type="button"[^>]*disabled=""[^>]*>Установить и войти для подтверждения/) + match(html, /minLength="3" maxLength="16" pattern="\[A-Za-z0-9_\]\{3,16\}" required=""/) + equal(launchAccess(unlinked), 'link') + + const signedOut = renderToStaticMarkup() + doesNotMatch(signedOut, /Установить и войти для подтверждения/) + const linked = { username: 'website_login', links: [{ server_id: 'aoc', mc_username: 'Bound_Name' }] } + const linkedHtml = renderToStaticMarkup() + doesNotMatch(linkedHtml, /Установить и войти для подтверждения/) + equal(launchAccess(linked), 'ready') +}) + +test('a verified pack does not make an unlinked account ready for normal play', () => { + const access = launchAccess({ username: 'website_login', links: [] }) + const html = renderToStaticMarkup( {}} onRepair={() => {}} onLegacy={() => {}} />) + match(html, /Нужно привязать ник/) + doesNotMatch(html, /Сборка готова|файлов под контролем/) + match(html, /Проверено файлов сборки: 12/) +}) + +test('incomplete inspection does not claim that all pack files were verified', () => { + const html = renderToStaticMarkup( {}} onRepair={() => {}} onLegacy={() => {}} />) + match(html, /Требуется проверка/) + match(html, /Файлов в сборке: 12/) + doesNotMatch(html, /Проверено файлов сборки|файлов под контролем/) +}) + +test('legacy review opens with no selected files and remains closable while its list is loading', () => { + const html = renderToStaticMarkup( {}} onChanged={() => {}} />) + match(html, /role="dialog" aria-modal="true" aria-labelledby="legacy-title" aria-describedby="legacy-description"/) + match(html, /По умолчанию ничего не выбрано/) + match(html, /