Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
728fed7de3 | ||
|
|
b674347e86 | ||
|
|
a7eee390ab | ||
|
|
5b741771b4 | ||
|
|
799fa692ef | ||
|
|
69946e62f1 | ||
|
|
fd8a6a87a1 | ||
|
|
b43fc610c4 | ||
|
|
effe683a40 | ||
|
|
260e4577d0 | ||
|
|
bb4b1f8051 | ||
|
|
bf43254c57 |
@@ -1,2 +0,0 @@
|
|||||||
# Signed updater fixtures must retain their exact committed bytes on every OS.
|
|
||||||
src-tauri/tests/fixtures/updater/* -text
|
|
||||||
+60
-49
@@ -1,83 +1,94 @@
|
|||||||
name: Cross-platform build (disposable signatures)
|
name: Cross-platform build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, codex/server-migration-20260917]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: build-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: CI test bundle / ${{ matrix.platform }}
|
name: ${{ matrix.name }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- platform: linux-x86_64
|
- name: Linux x64
|
||||||
os: ubuntu-22.04
|
os: ubuntu-22.04
|
||||||
target: x86_64-unknown-linux-gnu
|
args: --bundles appimage,deb
|
||||||
bundles: appimage,deb
|
- name: Windows x64
|
||||||
- platform: windows-x86_64
|
|
||||||
os: windows-2022
|
os: windows-2022
|
||||||
target: x86_64-pc-windows-msvc
|
args: --bundles nsis,msi
|
||||||
bundles: nsis,msi
|
- name: macOS Apple Silicon
|
||||||
- platform: darwin-aarch64
|
|
||||||
os: macos-15
|
os: macos-15
|
||||||
target: aarch64-apple-darwin
|
args: --target aarch64-apple-darwin --bundles app,dmg
|
||||||
bundles: dmg,app
|
- name: macOS Intel
|
||||||
- platform: darwin-x86_64
|
|
||||||
os: macos-15-intel
|
os: macos-15-intel
|
||||||
target: x86_64-apple-darwin
|
args: --target x86_64-apple-darwin --bundles app,dmg
|
||||||
bundles: dmg,app
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: bash
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
- name: Install Linux system dependencies
|
||||||
targets: ${{ matrix.target }}
|
|
||||||
- name: Install Linux desktop dependencies
|
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
run: |
|
run: |
|
||||||
# Ubuntu 22.04 keeps distro sources here; unrelated vendor repos are not needed.
|
sudo apt-get update
|
||||||
test -s /etc/apt/sources.list
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
apt_options=(
|
|
||||||
-o Dir::Etc::sourcelist=/etc/apt/sources.list
|
|
||||||
-o Dir::Etc::sourceparts=-
|
|
||||||
)
|
|
||||||
sudo apt-get "${apt_options[@]}" --error-on=any update
|
|
||||||
sudo apt-get "${apt_options[@]}" install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm test
|
- run: npm test
|
||||||
- run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml
|
|
||||||
- run: python -m unittest discover -s scripts -p 'release_test.py' -v
|
|
||||||
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
|
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
|
||||||
- name: Generate disposable CI key (never a production secret)
|
# These are build/test artifacts. Release signing happens separately with
|
||||||
|
# the operator-held key; CI never receives that key or publishes stable.json.
|
||||||
|
- run: npm run tauri:build -- --config scripts/tauri-unsigned.json ${{ matrix.args }}
|
||||||
|
- name: Prepare unsigned macOS updater archive
|
||||||
|
if: runner.os == 'macOS'
|
||||||
run: |
|
run: |
|
||||||
python scripts/release.py ci-key --directory "$RUNNER_TEMP/ci-updater"
|
python3 - <<'PY'
|
||||||
python -c "import json,os; open(os.environ['GITHUB_ENV'],'a').write('RELEASE_VERSION='+json.load(open('package.json'))['version']+'\n')"
|
import json
|
||||||
- name: Build packages and updater bundles
|
from pathlib import Path
|
||||||
run: npm run tauri:build -- --ci --target '${{ matrix.target }}' --bundles '${{ matrix.bundles }}' --config "$RUNNER_TEMP/ci-updater/updater-build.json"
|
import tarfile
|
||||||
- name: Collect and verify every test bundle
|
apps = list(Path('src-tauri/target').glob('*/release/bundle/macos/*.app'))
|
||||||
run: python scripts/release.py collect --bundle 'src-tauri/target/${{ matrix.target }}/release/bundle' --directory ci-packages --platform '${{ matrix.platform }}' --version "$RELEASE_VERSION"
|
if len(apps) != 1:
|
||||||
- uses: actions/upload-artifact@v4
|
raise SystemExit('Expected exactly one macOS application bundle')
|
||||||
|
app = apps[0]
|
||||||
|
version = json.loads(Path('src-tauri/tauri.conf.json').read_text())['version']
|
||||||
|
architecture = app.parts[2].split('-')[0]
|
||||||
|
archive = app.parent / f'ShaCraft.Launcher_{version}_{architecture}.app.tar.gz'
|
||||||
|
with tarfile.open(archive, 'w:gz') as output:
|
||||||
|
output.add(app, arcname=app.name)
|
||||||
|
PY
|
||||||
|
- name: Upload Windows installers
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: CI-NOT-FOR-RELEASE-${{ matrix.platform }}
|
name: shacraft-launcher-windows-x64
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7
|
path: |
|
||||||
path: ci-packages/*
|
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
|
||||||
|
src-tauri/target/*/release/bundle/macos/*.app.tar.gz
|
||||||
|
|||||||
+26
-13
@@ -13,6 +13,30 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
publisher:
|
||||||
|
# Ubuntu 22.04 has no minisign package. Keep the desktop compatibility
|
||||||
|
# check there, but exercise real signature verification on Ubuntu 24.04.
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: sudo apt-get update && sudo apt-get install -y minisign
|
||||||
|
- run: python3 -m unittest discover -s scripts -p 'test_*.py'
|
||||||
|
admission-client:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: '25'
|
||||||
|
- name: Check the Minigames admission companion
|
||||||
|
working-directory: admission-client
|
||||||
|
run: ./gradlew test build --no-daemon
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: shacraft-admission-client
|
||||||
|
if-no-files-found: error
|
||||||
|
path: admission-client/build/libs/shacraft-admission-client-0.1.0.jar
|
||||||
check:
|
check:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
@@ -21,23 +45,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- name: Install Linux desktop dependencies
|
- name: Install Linux desktop dependencies
|
||||||
run: |
|
run: |
|
||||||
# Ubuntu 22.04 keeps distro sources here; unrelated vendor repos are not needed.
|
sudo apt-get update
|
||||||
test -s /etc/apt/sources.list
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
||||||
apt_options=(
|
|
||||||
-o Dir::Etc::sourcelist=/etc/apt/sources.list
|
|
||||||
-o Dir::Etc::sourceparts=-
|
|
||||||
)
|
|
||||||
sudo apt-get "${apt_options[@]}" --error-on=any update
|
|
||||||
sudo apt-get "${apt_options[@]}" install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm test
|
- run: npm test
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
- run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml
|
|
||||||
- run: python -m unittest discover -s scripts -p 'release_test.py' -v
|
|
||||||
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
|
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
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: |
|
|
||||||
# Ubuntu 22.04 keeps distro sources here; unrelated vendor repos are not needed.
|
|
||||||
test -s /etc/apt/sources.list
|
|
||||||
apt_options=(
|
|
||||||
-o Dir::Etc::sourcelist=/etc/apt/sources.list
|
|
||||||
-o Dir::Etc::sourceparts=-
|
|
||||||
)
|
|
||||||
sudo apt-get "${apt_options[@]}" --error-on=any update
|
|
||||||
sudo apt-get "${apt_options[@]}" 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"
|
|
||||||
+3
-1
@@ -11,4 +11,6 @@ src-tauri/gen/
|
|||||||
*.pfx
|
*.pfx
|
||||||
*.sig
|
*.sig
|
||||||
|
|
||||||
__pycache__/
|
admission-client/.gradle/
|
||||||
|
admission-client/build/
|
||||||
|
graphify-out/
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
|
|
||||||
Cross-platform desktop launcher for the ShaCraft Minecraft network. It is a
|
Cross-platform desktop launcher for the ShaCraft Minecraft network. It is a
|
||||||
Tauri 2 application: React/Vite is the UI and Rust owns all filesystem,
|
Tauri 2 application: React/Vite is the UI and Rust owns all filesystem,
|
||||||
network and process-adjacent work. The current production profile is
|
network and process-adjacent work. Profiles are **Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21)
|
||||||
**Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21).
|
and **Minigames** (Minecraft 26.2, Fabric 0.19.5, Java 25). See the Minigames
|
||||||
|
integration record below and `docs/release-0.1.6.md` for publication evidence.
|
||||||
|
|
||||||
This repository owns the launcher only. The server-side API and published
|
This repository owns the launcher only. The server-side API and published
|
||||||
payload are in `/root/shacraft` on the ShaCraft host; see
|
payload are in `/root/shacraft` on the ShaCraft host; see
|
||||||
@@ -21,8 +22,9 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
|||||||
|
|
||||||
## Trust model
|
## Trust model
|
||||||
|
|
||||||
- The only supported remote profile manifest endpoint is
|
- The only supported remote profile manifest endpoints are the fixed
|
||||||
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest`.
|
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest` and
|
||||||
|
`https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest`.
|
||||||
The read-only Aeronautics player-count endpoint
|
The read-only Aeronautics player-count endpoint
|
||||||
`https://shacraft.ru/api/online/aoc` is also hardcoded in `remote.rs`; it
|
`https://shacraft.ru/api/online/aoc` is also hardcoded in `remote.rs`; it
|
||||||
is display-only and is never allowed to influence downloads or launching.
|
is display-only and is never allowed to influence downloads or launching.
|
||||||
@@ -30,12 +32,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
|||||||
embedded public key and `keyId` **before** parsing the payload.
|
embedded public key and `keyId` **before** parsing the payload.
|
||||||
- `src-tauri/src/manifest.rs` then validates paths, SHA-256, sizes, HTTPS and
|
- `src-tauri/src/manifest.rs` then validates paths, SHA-256, sizes, HTTPS and
|
||||||
allowed ShaCraft hosts. Do not weaken this whitelist.
|
allowed ShaCraft hosts. Do not weaken this whitelist.
|
||||||
- `profile.rs` / `inventory.rs` stage verified files, journal replacements and
|
- `src-tauri/src/profile.rs` downloads to a temporary sibling file, verifies
|
||||||
retire only previously owned, unchanged managed files. Unknown files are not
|
size + SHA-256, and atomically replaces only launcher-managed files.
|
||||||
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
|
- This ShaCraft manifest is the **only** source of truth for which
|
||||||
Minecraft version / NeoForge version / Java major a profile needs
|
Minecraft version / NeoForge version / Java major a profile needs
|
||||||
(`Manifest.minecraft`) and for mod/config files. It never supplies a URL
|
(`Manifest.minecraft`) and for mod/config files. It never supplies a URL
|
||||||
@@ -45,29 +43,62 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
|||||||
control a URL in any of those domains.
|
control a URL in any of those domains.
|
||||||
- ShaCraft accounts: `src-tauri/src/shacraft_account.rs` talks only to the
|
- ShaCraft accounts: `src-tauri/src/shacraft_account.rs` talks only to the
|
||||||
hardcoded `https://shacraft.ru` origin. Passwords are never persisted. The
|
hardcoded `https://shacraft.ru` origin. Passwords are never persisted. The
|
||||||
revocable session token is stored locally with mode 600 on Unix. At launch,
|
revocable session token is stored locally with mode 600 on Unix and never
|
||||||
the nickname is fetched from the verified `aoc` account link; the legacy
|
passed to Java. The new admission flow uses fixed POST endpoints
|
||||||
nickname in `settings.json` is ignored as an identity source. Server-side
|
`/api/launcher/v2/admission/nickname` and
|
||||||
whitelist enforcement and LoginSystem remain the final access-control
|
`/api/launcher/v2/admission/tickets`; redirects are rejected. Neither the
|
||||||
boundary, including for old launcher versions. The explicit onboarding command
|
manifest nor the webview can select their origin or URL.
|
||||||
is the only unlinked launch path: a short-lived server grant goes to the game
|
- Free nicknames are claimed directly for the authenticated account. Existing
|
||||||
child environment only, never IPC responses, argv or files. The signed pack
|
player names are reserved server-side and assigned by an administrator;
|
||||||
must contain ShaCraft Game Bridge; it binds the grant to the game session,
|
deleting a website account must not make an existing player's name free.
|
||||||
requires LoginSystem authentication and an explicit one-time proof command.
|
- After installation, `admission.rs` generates an ephemeral Ed25519 key with
|
||||||
Existing links retain legacy provenance; status polling cannot create links.
|
the OS CSPRNG. The backend binds its public key to a one-use ticket, current
|
||||||
|
account session, canonical `aoc` nickname and server access. Only that
|
||||||
- Launcher self-update is a separate trust domain in `updater.rs` and
|
returned nickname selects launch identity; `settings.json` is never a fallback.
|
||||||
`updater/protocol.rs`: the fixed GitHub repository's `latest.json` bytes are
|
Ticket and PKCS#8 private key go only in the final Java child's environment
|
||||||
authenticated with the pinned Tauri/minisign public key before JSON parsing.
|
(`SHACRAFT_ADMISSION_TICKET`, `SHACRAFT_ADMISSION_PRIVATE_KEY`). Never put
|
||||||
Each exact version/platform artifact also requires that signature, SHA-256
|
them in global environment, argv/argfiles, settings, logs or IPC. A fresh
|
||||||
and size. Never reuse the ShaCraft mod-manifest key or accept IPC URLs/keys.
|
launch obtains a fresh ticket; there is no shared launcher secret.
|
||||||
- Linux self-update requires the original ordinary AppImage file and the frozen
|
- Once admission is enforced on Aeronautics, its server mod verifies the
|
||||||
Tauri `APPDIR` bound to the running `usr/bin/shacraft-launcher`. Extracted or
|
challenge/proof before world entry, and server-side whitelist enforcement
|
||||||
inherited AppImage context is manual-only; never replace a bare binary.
|
remains a final access boundary. Replace LoginSystem only as part of the
|
||||||
- `update_guard.rs` retains one launcher-instance OS lock, drains native writes
|
validated `aoc` rollout; other servers are unaffected. Old launchers without
|
||||||
through `operations::Lifecycle`, and checks the existing detached-game lease.
|
admission proof will be rejected after enforcement. This authenticates an
|
||||||
`launcher-state/pending-update.json` survives installer handoff; only startup
|
account's permission, not the integrity of an unmodified launcher binary.
|
||||||
of its exact target version acknowledges it. Never clear it on a timer.
|
- Application updates are a separate trust domain from Minecraft profiles.
|
||||||
|
The only channel is `https://shacraft.ru/launcher/updates/stable.json`.
|
||||||
|
Both release metadata and the installer need a valid Minisign signature
|
||||||
|
under the separate updater public key embedded in `tauri.conf.json`.
|
||||||
|
Never reuse the profile signing key, accept unsigned metadata, or allow IPC
|
||||||
|
to select an update URL, key, version, installer argument or destination.
|
||||||
|
Downloads require HTTPS without redirects on exact `shacraft.ru`, below
|
||||||
|
`/downloads/shacraft-launcher/<signed-version>/`, and are bounded to 256 MiB.
|
||||||
|
Stable versions must increase. The native layer owns every candidate.
|
||||||
|
- Linux self-update supports AppImage and an installed `sha-craft-launcher`
|
||||||
|
deb. AppImage preserves executable permissions and uses same-directory
|
||||||
|
atomic replacement after verification. Deb selects only the signed
|
||||||
|
`linux-x86_64-deb` entry; retain legacy `linux-x86_64` AppImage metadata for
|
||||||
|
installed 0.1.3 clients. Never silently switch installation formats.
|
||||||
|
- Deb elevation uses only `/usr/bin/pkexec --disable-internal-agent` and the
|
||||||
|
root-owned installed `/usr/bin/shacraft-launcher --shacraft-install-deb`.
|
||||||
|
This early helper mode never starts GTK/Tauri or account/network code. It
|
||||||
|
receives bounded metadata/package bytes over stdin, not paths or commands,
|
||||||
|
and re-verifies both signatures with the embedded key as root. Before the
|
||||||
|
fixed dpkg installation, require exact package name, architecture and signed
|
||||||
|
version, root-only staging and monotonic installed-package version; pass
|
||||||
|
`--refuse-downgrade` to dpkg to close concurrent-update races. No system
|
||||||
|
password collection, sudo fallback or permissive polkit policy is allowed.
|
||||||
|
Cancellation, denied authorization and a busy package manager stay distinct.
|
||||||
|
Windows/macOS use the pinned Tauri installer implementation; the vendored
|
||||||
|
updater change only exposes construction from already verified metadata to
|
||||||
|
avoid a second, unbounded remote JSON request. See its patch notes.
|
||||||
|
Installation holds game/install/account permits until restart. The game
|
||||||
|
permit lasts until the tracked Java child exits. These are process-local
|
||||||
|
guards; another launcher process is not a cross-process lock.
|
||||||
|
- The updater signing private key stays outside Git on the operator's local
|
||||||
|
machine; CI receives no production key. Publish only verified packages,
|
||||||
|
public signatures and signed feed. Updater signatures are separate from
|
||||||
|
Windows Authenticode and macOS code signing/notarization.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -81,8 +112,9 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
|||||||
- `lib.rs` — module/command registration only; `commands/` holds adapters
|
- `lib.rs` — module/command registration only; `commands/` holds adapters
|
||||||
for account/game/host/preferences/profiles. Unsigned sync/inspect IPC was
|
for account/game/host/preferences/profiles. Unsigned sync/inspect IPC was
|
||||||
removed; only verified remote manifests may drive profile mutations.
|
removed; only verified remote manifests may drive profile mutations.
|
||||||
- `operations.rs` — lifecycle and install/account permits owned by workers.
|
- `operations.rs` — process-local install/account permits owned by workers.
|
||||||
Launch must use the authenticated ShaCraft nickname; no settings fallback.
|
The account permit covers ticket issuance through Java spawn, preventing
|
||||||
|
local logout/account switching from racing that handoff.
|
||||||
- `storage.rs` — unique same-directory atomic writes, owner-only Unix files.
|
- `storage.rs` — unique same-directory atomic writes, owner-only Unix files.
|
||||||
- `trusted_http.rs` — HTTPS and exact-host redirect policy per game provider.
|
- `trusted_http.rs` — HTTPS and exact-host redirect policy per game provider.
|
||||||
- `download.rs` — shared verified-download helper (temp file, hash,
|
- `download.rs` — shared verified-download helper (temp file, hash,
|
||||||
@@ -91,17 +123,22 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
|||||||
build on this rather than each rolling their own.
|
build on this rather than each rolling their own.
|
||||||
- `mojang.rs` — vanilla Minecraft trust boundary + the generic
|
- `mojang.rs` — vanilla Minecraft trust boundary + the generic
|
||||||
`inheritsFrom` version-JSON merge (shared with NeoForge's profile).
|
`inheritsFrom` version-JSON merge (shared with NeoForge's profile).
|
||||||
- `neoforge.rs` / `neoforge_repair.rs` — verified official installer, isolated
|
- `neoforge.rs` — runs NeoForge's official installer headlessly.
|
||||||
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.
|
- `runtime.rs` — Java 21 auto-provisioning via Eclipse Adoptium.
|
||||||
- `msa.rs` — Microsoft/Xbox/Minecraft Services login; see
|
- `msa.rs` — Microsoft/Xbox/Minecraft Services login; see
|
||||||
`MSA_CLIENT_ID`'s doc comment before touching login — it is currently a
|
`MSA_CLIENT_ID`'s doc comment before touching login — it is currently a
|
||||||
placeholder pending ShaCraft's own Azure AD app registration and
|
placeholder pending ShaCraft's own Azure AD app registration and
|
||||||
Minecraft-API approval.
|
Minecraft-API approval.
|
||||||
- `shacraft_account.rs` — local ShaCraft login/registration, session and
|
- `shacraft_account.rs` — local ShaCraft login/registration, session and
|
||||||
verified nickname-link API.
|
nickname claim/admission API; legacy link commands remain for compatibility.
|
||||||
- `launch.rs` — builds and spawns the actual `java` process.
|
- `admission.rs` — ephemeral key generation, strict ticket response validation,
|
||||||
|
canonical identity and child-only admission environment.
|
||||||
|
- `launch.rs` — builds and spawns the actual `java` process; admission secrets
|
||||||
|
must remain outside its argument substitution and JVM argfile paths.
|
||||||
|
- `updater.rs`, `commands/updater.rs` — authenticated release metadata,
|
||||||
|
bounded package download, platform installation and guarded restart.
|
||||||
|
- `deb_updater.rs` — installed-package checks, one system authentication
|
||||||
|
prompt and the bounded, signature-verifying non-GUI root helper.
|
||||||
- `src-tauri/src/settings.rs` — durable local preferences; maintain backward
|
- `src-tauri/src/settings.rs` — durable local preferences; maintain backward
|
||||||
compatibility with already-written JSON.
|
compatibility with already-written JSON.
|
||||||
- `docs/manifest-v1.md` — signed manifest envelope and payload contract
|
- `docs/manifest-v1.md` — signed manifest envelope and payload contract
|
||||||
@@ -109,15 +146,9 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
|||||||
- `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium
|
- `docs/game-trust-boundary.md` — the Mojang/NeoForge/Microsoft/Adoptium
|
||||||
trust domains used to install and run the game itself.
|
trust domains used to install and run the game itself.
|
||||||
- `.github/workflows/check.yml` — push/PR UI checks and Linux Rust tests.
|
- `.github/workflows/check.yml` — push/PR UI checks and Linux Rust tests.
|
||||||
Ubuntu 22.04 dependency steps use only the runner main Ubuntu source list;
|
- `.github/workflows/build.yml` — main-push/manual cross-platform CI artifacts;
|
||||||
keep APT signature/hash checks and fail on index errors. Vendor PPAs are not needed.
|
updater signing is explicitly disabled there. Local release signing and
|
||||||
- `.github/workflows/build.yml` — four-platform CI packages with disposable test
|
atomic feed publication are documented in `docs/launcher-updates.md`.
|
||||||
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
|
## Verification
|
||||||
|
|
||||||
@@ -136,6 +167,14 @@ not prove Tauri commands work.
|
|||||||
|
|
||||||
See `PLAN.md` for known gaps. Never label browser preview or unit tests as
|
See `PLAN.md` for known gaps. Never label browser preview or unit tests as
|
||||||
a successful cold game install / Microsoft OAuth / Windows/macOS beta test.
|
a successful cold game install / Microsoft OAuth / Windows/macOS beta test.
|
||||||
|
The admission implementation has local unit/UI checks and unsigned Linux
|
||||||
|
0.1.2 AppImage/deb packages built on Ubuntu 26.04. Older Ubuntu compatibility
|
||||||
|
has not been tested. The backend/mod rollout is active on Aeronautics as of
|
||||||
|
2026-09-10. Real isolated NeoForge admission tests and a production rejection
|
||||||
|
without the mod passed; these do not certify a full production modpack join.
|
||||||
|
The server has a required early duplicate-login guard so unauthenticated
|
||||||
|
connections cannot evict an already-online UUID. The client mod pins the actual
|
||||||
|
socket to `135.106.154.86:25567`. See architecture and server rollout records.
|
||||||
|
|
||||||
## Working conventions
|
## Working conventions
|
||||||
|
|
||||||
@@ -145,3 +184,93 @@ a successful cold game install / Microsoft OAuth / Windows/macOS beta test.
|
|||||||
them. Inspect `git status` before staging.
|
them. Inspect `git status` before staging.
|
||||||
- Update this file and `docs/launcher-architecture.md` whenever the trust
|
- Update this file and `docs/launcher-architecture.md` whenever the trust
|
||||||
model, endpoint contract, storage layout, or release workflow changes.
|
model, endpoint contract, storage layout, or release workflow changes.
|
||||||
|
|
||||||
|
|
||||||
|
## Cross-platform release 0.1.5 (2026-09-10)
|
||||||
|
|
||||||
|
Published Windows x64 EXE/MSI, macOS aarch64 and x86_64 DMG/app.tar.gz,
|
||||||
|
and Linux x64 AppImage/DEB on https://shacraft.ru/help#launcher. The signed
|
||||||
|
stable feed includes all platforms plus the legacy/exact Linux aliases.
|
||||||
|
CI source b43fc610c43a9ec9f5f3ffce601de9604670ff8a, successful Actions run
|
||||||
|
https://github.com/emil28092005/shacraft-launcher/actions/runs/34512683651.
|
||||||
|
An initial non-Linux borrow/move compilation error in updater target selection
|
||||||
|
was fixed before the final build. Native tests: Windows70, macOS74 per arch,
|
||||||
|
Linux84; UI tests pass on all four runners. Local checks verify macOS bundle
|
||||||
|
version/CPU type, Linux package identity and every artifact signature. Public
|
||||||
|
HTTPS downloads of all eight artifacts match local SHA-256. Website418 tests
|
||||||
|
plus48 subtests pass; only backend recreated, game containers unchanged.
|
||||||
|
Updater signatures use the existing operator-held key, never uploaded to CI or
|
||||||
|
server. Windows installers have no Authenticode signature; macOS is not Apple
|
||||||
|
notarized. Native CI tests and packaging do not certify full Minecraft installs
|
||||||
|
or desktop updater/restart behavior on Windows/macOS. Older unsupported clients
|
||||||
|
need a manual installation of the current release. Previous releases immutable.
|
||||||
|
|
||||||
|
|
||||||
|
## Admission client menu 0.1.1 (2026-09-11)
|
||||||
|
|
||||||
|
The signed Aeronautics payload now contains admission mod 0.1.1 at the existing
|
||||||
|
managed path `mods/shacraft-admission-0.1.0.jar` to prevent duplicate mod IDs on
|
||||||
|
upgrade. SHA-256: `faa9ae13cb0f2d93c03dae26ab36ae20d3fb6b66c89c09254b16808d6b183f89`.
|
||||||
|
From the title screen (and vanilla safety acknowledgement), Multiplayer connects
|
||||||
|
to fixed `135.106.154.86:25567`. Cancel/errors return to TitleScreen; transitions
|
||||||
|
from other screens do not auto-connect. Client-only registration, protocol 1 and
|
||||||
|
one-use admission remain unchanged. Running game server was not restarted.
|
||||||
|
Linux Java 21 build and 8 mod tests pass. An opt-in native live test verifies
|
||||||
|
signed-manifest retrieval and download/repair/restoration of the admission jar
|
||||||
|
only in a temporary directory. Mac 0.1.5 connection failure remains unclassified
|
||||||
|
pending exact error/log; this is not a verified macOS fix or desktop UI test.
|
||||||
|
|
||||||
|
## Minigames integration (2026-09-13, published in 0.1.6)
|
||||||
|
|
||||||
|
- Native profile mapping is fixed: aeronautics → aoc; minigames → minigames.
|
||||||
|
Both display/claim the canonical existing aoc nickname. The backend enforces
|
||||||
|
the current shared aoc subscription and whitelist for Minigames as well.
|
||||||
|
Ticket requests and responses remain bound to the selected server; never
|
||||||
|
accept an aoc ticket as a Minigames ticket.
|
||||||
|
- `fabric.rs` adds independent exact HTTPS domains `meta.fabricmc.net` and
|
||||||
|
`maven.fabricmc.net`. It verifies profile identity/parent/main class, bounded
|
||||||
|
metadata, portable Maven coordinates, hashes and sizes before the existing
|
||||||
|
atomic library installer. Unknown loaders now fail manifest validation.
|
||||||
|
- Minigames launches with a native-owned Quick Play endpoint
|
||||||
|
`135.106.154.86:25568`. The manifest cannot choose a game destination.
|
||||||
|
- `admission-client/` owns the small client-only Fabric 26.2 companion. Its
|
||||||
|
configuration-phase proof uses `minigames` in the existing Ed25519 transcript,
|
||||||
|
verifies the actual socket, canonical nickname and nonce, and signs once per
|
||||||
|
process. Only a fresh launch can retry a consumed ticket. Java receives only
|
||||||
|
the ephemeral ticket and private key in its child environment, never the
|
||||||
|
website session/password. Keep server verification on Paper before world
|
||||||
|
entry with an early duplicate UUID guard.
|
||||||
|
- The standalone Paper admission adapter and backend remain server-project
|
||||||
|
responsibilities. Do not put map/SMASH source into this launcher repository.
|
||||||
|
- Production updater publication requires a separately built, monotonically
|
||||||
|
newer launcher release and existing operator signatures. Source tests or a
|
||||||
|
client jar alone do not update installed 0.1.5 launchers.
|
||||||
|
|
||||||
|
Launcher 0.1.6 was built from `799fa692` on `codex/launcher-updater`; divergent
|
||||||
|
`main` remains unchanged. All four native CI builds, UI/native checks and 18
|
||||||
|
publisher signature tests pass. The unchanged Fabric companion completed a
|
||||||
|
real Minecraft 26.2 → Paper configuration handshake with a synthetic account;
|
||||||
|
its CI artifact is byte-identical. All eight public packages and the stable
|
||||||
|
feed were signed with the existing local updater key and verified through
|
||||||
|
public HTTPS downloads. See `docs/release-0.1.6.md` and its committed receipt.
|
||||||
|
This records successful release/admission verification, not a Windows/macOS
|
||||||
|
cold install or OS signing/notarization certification.
|
||||||
|
|
||||||
|
## Server migration — launcher 0.1.7 (2026-09-17)
|
||||||
|
|
||||||
|
Minigames Quick Play now uses the fixed native endpoint `shacraft.ru:25568`.
|
||||||
|
It no longer pins the retired server IP in the application binary. The Fabric
|
||||||
|
admission client accepts the new actual socket IP `135.106.219.182` and retains
|
||||||
|
`135.106.154.86` for the temporary forwarding path; unrelated hosts and ports
|
||||||
|
remain rejected. The server published updated signed companion manifests.
|
||||||
|
|
||||||
|
The migration's full-platform packages are published from
|
||||||
|
`codex/server-migration-20260917`. Existing 0.1.6 installers still connect to the
|
||||||
|
old IP and need that forwarding path until upgraded. Version 0.1.7 uses a newly generated operator-held updater key and the fixed
|
||||||
|
`https://shacraft.ru/launcher/updates/stable-v2.json` channel. It requires one
|
||||||
|
manual installation. Preserve `stable.json` at the last old-key release; never
|
||||||
|
replace it with new-key metadata. Subsequent v2-channel releases use the new
|
||||||
|
key, kept only at `/home/emil/.local/share/shacraft-updater/production.key`.
|
||||||
|
All eight public HTTPS downloads, their signatures and the signed v2 feed were
|
||||||
|
verified after publication. See `docs/release-0.1.7.md` and its publication
|
||||||
|
receipt for provenance, checks and the limits of platform verification.
|
||||||
|
|||||||
@@ -17,32 +17,113 @@
|
|||||||
approval и живой OAuth-тест. Текущий запуск использует ShaCraft identity.
|
approval и живой OAuth-тест. Текущий запуск использует ShaCraft identity.
|
||||||
- [ ] Cold install / repair / update / game exit на чистых Windows/Linux/macOS.
|
- [ ] Cold install / repair / update / game exit на чистых Windows/Linux/macOS.
|
||||||
Unit tests и web preview не заменяют эти прогоны.
|
Unit tests и web preview не заменяют эти прогоны.
|
||||||
- [x] Native самообновление, русский UX, pinned updater key и защищённый release pipeline.
|
- [ ] Windows Authenticode / macOS signing-notarization и проверка установщиков.
|
||||||
- [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.
|
- [ ] Реальная отмена загрузок, журнал с редактированием токенов и retry UX.
|
||||||
- [ ] Выбор каталога профиля и безопасный reset только managed-файлов.
|
- [ ] Выбор каталога профиля и безопасный reset только managed-файлов.
|
||||||
- [ ] Keychain-хранилище сессии ShaCraft; OS-lock и lease игры уже реализованы.
|
- [ ] Keychain-хранилище refresh token; cross-process exclusion при необходимости.
|
||||||
- [ ] Динамический каталог и новости; реальный Aeronautics онлайн уже
|
- [ ] Динамический каталог и новости; реальный Aeronautics онлайн уже
|
||||||
загружается через фиксированный display-only API. Не имитировать данные.
|
загружается через фиксированный display-only API. Не имитировать данные.
|
||||||
|
|
||||||
## Исправления по handoff (исходники, до выкладки)
|
## Вход по одноразовому разрешению: внедрение 2026-09-10
|
||||||
|
|
||||||
- [x] Один signed snapshot и общий межпроцессный lock на Play/Repair.
|
- [x] Новый native claim закрепляет свободный ник за аккаунтом без входа в игру.
|
||||||
- [x] Inventory, journal/recovery, retirement старых неизменённых managed-файлов.
|
Старые имена резервируются backend и переносятся администратором.
|
||||||
- [x] Явный перенос выбранных legacy-модов в резервную копию.
|
- [x] После установки Rust создаёт Ed25519 ключ через OS CSPRNG и запрашивает
|
||||||
- [x] NeoForge clean rebuild + provenance receipt; непустая порча обнаруживается.
|
ticket на фиксированном ShaCraft API. Ник для запуска берётся из ticket;
|
||||||
- [x] Отдельный первый вход с серверным grant и одноразовым proof.
|
локальные настройки не могут его заменить.
|
||||||
- [x] RAM retry сохраняет намерение; неверный JAVA_HOME не скрывает подходящий PATH.
|
- [x] Ticket и PKCS#8 private key передаются только окружением дочерней Java;
|
||||||
- [x] Версии в интерфейсе берутся из проверенного manifest.
|
session сайта остаётся в native. В argv, argfile, settings, логах и IPC
|
||||||
- [ ] Совместная выкладка backend/Game Bridge/подписанного payload и нового лаунчера.
|
секретов нет. Account permit держится до spawn.
|
||||||
- [ ] Изолированная игровая проверка LoginSystem + hold + proof, затем beta по ОС.
|
- [x] Ошибки и результаты привязки показаны в диалоге; двойной клик не создаёт
|
||||||
|
параллельных запросов, старого polling нет.
|
||||||
|
- [x] Локально пройдены 64 Rust-теста (5 live-тестов пропущены), 22 UI-теста,
|
||||||
|
TypeScript/Vite и сборка Linux x86-64 `tauri:build -- --no-bundle`.
|
||||||
|
Шесть браузерных сценариев используют только замоканный Tauri IPC.
|
||||||
|
- [x] Собраны неподписанные AppImage и deb версии 0.1.2 на Ubuntu 26.04;
|
||||||
|
проверены извлечение AppImage и metadata deb. Работа на старых Ubuntu и
|
||||||
|
установка пакетов на чистой машине этим не подтверждены.
|
||||||
|
- [x] Выложены backend, подписанный клиентский мод и серверный мод Aeronautics;
|
||||||
|
сервер перезапущен и healthy. Whitelist сохранён; LoginSystem заменён только
|
||||||
|
на `aoc`. Пакеты Linux 0.1.2 опубликованы на сайте, AppImage запущен локально.
|
||||||
|
- [x] Настоящий изолированный NeoForge: новый ticket допускает в мир, отсутствие
|
||||||
|
мода/билета и повтор билета отклоняются. Старый LoginSystem на клиенте не мешает.
|
||||||
|
Подключение дубликата без авторизации не выбивает уже играющего владельца.
|
||||||
|
- [x] На публичном сервере подключение без мода отклонено до входа в мир;
|
||||||
|
HTTPS verifier из контейнера, подпись manifest и SHA-256 мода проверены.
|
||||||
|
Backend Docker: 404 tests + 48 subtests, включая истечение, отзыв session,
|
||||||
|
чужую identity, whitelist, резервирование имён и атомарное погашение.
|
||||||
|
- [x] Полный вход в production Aeronautics через установленный лаунчер
|
||||||
|
подтверждён пользователем 2026-09-10: «Присоединился!». Это пользовательское
|
||||||
|
подтверждение, а не автоматизированный cold-install тест.
|
||||||
|
- [ ] Проверить cold install и этот протокол на Windows/macOS, выпустить
|
||||||
|
подписанные пакеты. Локальная Linux-сборка не подтверждает эти платформы.
|
||||||
|
- [ ] Удобное переподключение: сейчас использованный или истёкший ticket
|
||||||
|
требует нового запуска игры из лаунчера; автоматического обновления нет.
|
||||||
|
|
||||||
Сопутствующий серверный код содержит read-only status, nonce proof с legacy
|
## Самообновление лаунчера 0.1.3: реализация 2026-09-10
|
||||||
migration и durable grant/revoke outbox. Оплата и доставка whitelist — разные
|
|
||||||
состояния. Реферальные правила остаются отдельной задачей серверного PLAN;
|
- [x] Отдельный канал обновления приложения на фиксированном HTTPS endpoint.
|
||||||
это исправление не меняет условия покупки. Продакшен не изменён.
|
Выделенный публичный ключ проверяет подпись metadata и пакета; версия,
|
||||||
|
заметки и URL связаны подписью. Redirect, downgrade и произвольные пути
|
||||||
|
из webview запрещены; размеры metadata и загрузки ограничены.
|
||||||
|
- [x] Vendored Tauri updater 2.11.0 принимает уже проверенный JSON через
|
||||||
|
`check_metadata` без второго HTTP-запроса. На Linux AppImage заменяется
|
||||||
|
атомарно через временный файл в том же каталоге с проверкой подписи и fsync.
|
||||||
|
- [x] Проверка при старте без автоматической установки; доступны уведомление
|
||||||
|
о новой версии, ручная проверка, заметки, прогресс, ошибки, повторная попытка
|
||||||
|
и явный перезапуск. Сбой проверки не блокирует установленный лаунчер.
|
||||||
|
- [x] Native guards исключают обновление во время игры и конфликтующих
|
||||||
|
операций. На время установки и до перезапуска заблокированы запуск игры,
|
||||||
|
ремонт сборки и изменения аккаунта. Для deb и development binary показано
|
||||||
|
сообщение об установке вручную; версия 0.1.2 требует первого ручного обновления.
|
||||||
|
- [x] Пройдены 76 Rust-тестов, 28 UI-тестов и 11 тестов publisher;
|
||||||
|
TypeScript/Vite успешно собраны. Девять браузерных сценариев с mock Tauri IPC
|
||||||
|
проверяют обновление, ошибки, повтор, блокировки и восстановление состояния;
|
||||||
|
внешняя сеть и реальные аккаунты в этих сценариях не используются.
|
||||||
|
- [x] Живой native-прогон скачал 0.1.3 с production HTTPS: повреждение
|
||||||
|
отклонено без изменения старого файла, подлинный пакет атомарно заменил
|
||||||
|
временную копию 0.1.2. Исходный AppImage сохранён, хеши проверены.
|
||||||
|
- [x] Опубликованы Linux 0.1.3 и подписанный stable feed; HTTPS 200,
|
||||||
|
`Cache-Control: no-store`, подписи и хеши проверены. Ссылка на сайте обновлена.
|
||||||
|
AppImage установлен в `~/Applications`, добавлен ярлык и проверен запуск.
|
||||||
|
- [ ] Полный GUI-цикл «Обновить → Перезапустить» проверить на следующем
|
||||||
|
релизе; текущий прогон проверяет native-установку и запуск пакета отдельно.
|
||||||
|
- [ ] Проверить установку и самообновление Windows/macOS перед публикацией
|
||||||
|
пакетов этих платформ; Authenticode/notarization остаются отдельными задачами.
|
||||||
|
|
||||||
|
## Обновление DEB 0.1.4: 2026-09-10
|
||||||
|
|
||||||
|
- [x] Отдельный подписанный deb entry, определение установленного формата
|
||||||
|
и сохранение AppImage-совместимости для клиентов 0.1.3.
|
||||||
|
- [x] Одно системное подтверждение через pkexec. Root helper до запуска GUI
|
||||||
|
повторно проверяет подписи и точные Package/Version/Architecture, получает
|
||||||
|
только bounded bytes через stdin и устанавливает пакет из root-only staging.
|
||||||
|
Пароль не попадает в лаунчер; отмена не открывает запасные окна авторизации.
|
||||||
|
- [x] Dpkg отказывается от downgrade и сохраняет системную блокировку пакетов.
|
||||||
|
Ошибки частичной установки не маскируются; автоматических повторов нет.
|
||||||
|
Read-only inspection имеет ограничение вывода и времени для группы процессов;
|
||||||
|
работающий dpkg не прерывается таймаутом посреди изменения пакета.
|
||||||
|
- [x] 32 UI-теста, TypeScript/Vite и 12 браузерных сценариев с mock IPC;
|
||||||
|
18 publisher-тестов с настоящими minisign и deb, включая переход feed 0.1.3.
|
||||||
|
- [x] 84 native-теста прошли. Реальный root-helper в изолированном Ubuntu 26.04
|
||||||
|
прошёл 10 сценариев: установка подписанного 0.1.4, повреждения, неверные
|
||||||
|
права/временный каталог, занятый dpkg, повтор и защита от downgrade.
|
||||||
|
Dpkg подтвердил версию, тестовый маркер профиля сохранён. GUI-подтверждение
|
||||||
|
PolicyKit этим контейнерным прогоном не проверялось; отмена покрыта UI/unit.
|
||||||
|
- [x] Опубликованы подписанные AppImage/deb 0.1.4, проверены байты feed и deb,
|
||||||
|
обе кнопки сайта и системная инструкция. Backend: 404 tests + 48 subtests,
|
||||||
|
Ruff clean. Minecraft не перезапускался, данные аккаунтов не менялись.
|
||||||
|
Deb 0.1.3 и ниже требуют первого ручного обновления до 0.1.4.
|
||||||
|
|
||||||
|
## Связанные серверные риски
|
||||||
|
|
||||||
|
Серверный план находится в `/root/shacraft/PLAN.md`. Для admission обязательны
|
||||||
|
атомарная одноразовая проверка ticket, привязка к текущим session/аккаунту/нику,
|
||||||
|
проверка до входа в мир и сохранение whitelist как серверного ограничения.
|
||||||
|
Старые игровые имена нельзя отдавать первому зарегистрировавшемуся; их
|
||||||
|
резервирование и назначение аккаунтам — отдельный этап миграции. Старый
|
||||||
|
NoGravity challenge-поток должен быть закрыт при включённом admission, чтобы
|
||||||
|
он не обходил резервирование имени. Остальные серверные задачи включают
|
||||||
|
enforcement реферальных правил и очередь повторов whitelist. Не менять
|
||||||
|
протокол незаметно в клиентском рефакторинге и не считать ticket доказательством
|
||||||
|
неизменённости клиентского бинарника.
|
||||||
|
|||||||
@@ -3,19 +3,15 @@
|
|||||||
Кроссплатформенный Tauri 2 лаунчер для [ShaCraft](https://shacraft.ru/):
|
Кроссплатформенный Tauri 2 лаунчер для [ShaCraft](https://shacraft.ru/):
|
||||||
React/TypeScript интерфейс, Rust — файлы, сеть и запуск процессов.
|
React/TypeScript интерфейс, Rust — файлы, сеть и запуск процессов.
|
||||||
|
|
||||||
Реализованы подписанная синхронизация Aeronautics, проверка/восстановление
|
Реализованы отдельные профили Aeronautics (Minecraft 1.21.1, NeoForge, Java 21)
|
||||||
модов и конфигурации, Java discovery/provisioning, bootstrap Minecraft и
|
и Minigames (Minecraft 26.2, Fabric, Java 25), подписанная синхронизация файлов,
|
||||||
NeoForge, настройки памяти, обработка установки/запуска/выхода. Проверка
|
проверка/восстановление модов и конфигурации, установка игры и Java, настройки
|
||||||
сборки теперь восстанавливает и игровые файлы; старые неизменённые managed-моды
|
памяти, обработка установки/запуска/выхода. Minigames опубликован в 0.1.6;
|
||||||
убираются в резервную копию, неизвестные моды разбираются явно пользователем.
|
проверки и результаты публикации — в [записи релиза](docs/release-0.1.6.md).
|
||||||
|
|
||||||
Вход выполняется через аккаунт ShaCraft — тот же, что на сайте. Игровой ник
|
Вход выполняется через аккаунт ShaCraft — тот же, что на сайте. Игровой ник
|
||||||
берётся только из подтверждённой привязки Aeronautics, а не из редактируемых
|
общий для обоих профилей и берётся только из подтверждённой привязки `aoc`.
|
||||||
локальных настроек. Пароли не сохраняются; сессию можно отозвать.
|
Тикет входа привязан к выбранному серверу; локальные настройки не выбирают личность. Пароли не сохраняются; сессию можно отозвать.
|
||||||
Для первого входа без привязки добавлено отдельное действие в настройках
|
|
||||||
аккаунта: установка → серверное разрешение → LoginSystem → одноразовая команда
|
|
||||||
подтверждения. Оно требует совместной выкладки backend, Game Bridge и
|
|
||||||
подписанного модпака; наличие исходников не означает публикацию этого сценария.
|
|
||||||
Microsoft OAuth-модуль сохранён отдельно, но не используется текущим
|
Microsoft OAuth-модуль сохранён отдельно, но не используется текущим
|
||||||
сценарием запуска; для его активации потребуются client ID и API approval.
|
сценарием запуска; для его активации потребуются client ID и API approval.
|
||||||
|
|
||||||
@@ -43,14 +39,30 @@ cargo test --locked --manifest-path src-tauri/Cargo.toml
|
|||||||
|
|
||||||
Build включает строгий TypeScript. GitHub Actions проверяет UI и Rust на
|
Build включает строгий TypeScript. GitHub Actions проверяет UI и Rust на
|
||||||
push/PR; workflow на main-push/ручном запуске собирает Windows x64, Linux x64, macOS Intel
|
push/PR; workflow на main-push/ручном запуске собирает Windows x64, Linux x64, macOS Intel
|
||||||
и Apple Silicon и сохраняет артефакты. Подпись релиза/автообновления ещё впереди.
|
и Apple Silicon и сохраняет неподписанные артефакты. Релизный оператор
|
||||||
|
подписывает проверенные пакеты и metadata локальным ключом; CI его не получает.
|
||||||
Используемые macOS runners соответствуют [списку GitHub](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
|
Используемые macOS runners соответствуют [списку GitHub](https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
|
||||||
|
|
||||||
|
## Обновление лаунчера
|
||||||
|
|
||||||
|
С версии 0.1.3 настройки содержат проверку обновлений, установку с прогрессом
|
||||||
|
и перезапуск. Лаунчер также проверяет новые версии при старте, но устанавливает
|
||||||
|
их только по кнопке. Подписи пакета и сведений о версии обязательны.
|
||||||
|
Закройте запущенный Minecraft перед установкой обновления.
|
||||||
|
|
||||||
|
В Linux обновляются AppImage и установленный deb (с версии 0.1.4).
|
||||||
|
Для deb система запрашивает права администратора; пароль не передаётся лаунчеру.
|
||||||
|
Deb 0.1.3 и ниже нужно один раз обновить вручную до 0.1.4. Dev-бинарник
|
||||||
|
обновляется вручную. С версии 0.1.2 нужен ручной переход на новый AppImage.
|
||||||
|
Пакеты Windows/macOS 0.1.5 опубликованы. Проверка реальной установки на каждой
|
||||||
|
платформе остаётся отдельной от сборки и автоматических тестов.
|
||||||
|
|
||||||
## Навигация
|
## Навигация
|
||||||
|
|
||||||
- [Архитектура](docs/launcher-architecture.md) — компоненты, данные, IPC.
|
- [Архитектура](docs/launcher-architecture.md) — компоненты, данные, IPC.
|
||||||
- [Trust boundaries](docs/game-trust-boundary.md) — доверенные источники игры.
|
- [Trust boundaries](docs/game-trust-boundary.md) — доверенные источники игры.
|
||||||
- [Manifest](docs/manifest-v1.md) — подписанный контракт модпака.
|
- [Manifest](docs/manifest-v1.md) — подписанный контракт модпака.
|
||||||
|
- [Обновления](docs/launcher-updates.md) — подпись, публикация и восстановление.
|
||||||
- [PLAN.md](PLAN.md) — ограничения и следующие шаги.
|
- [PLAN.md](PLAN.md) — ограничения и следующие шаги.
|
||||||
- [AGENTS.md](AGENTS.md) — инструкции для следующего разработчика/агента.
|
- [AGENTS.md](AGENTS.md) — инструкции для следующего разработчика/агента.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# ShaCraft Minigames admission client
|
||||||
|
|
||||||
|
Client-only Fabric companion for Minecraft 26.2, Java 25, Fabric Loader 0.19.5
|
||||||
|
and Fabric API 0.160.0+26.2. This is account admission, not an anti-cheat or a
|
||||||
|
proof that the original launcher binary is running.
|
||||||
|
|
||||||
|
Build with Java 25: `./gradlew test build --no-daemon`.
|
||||||
|
Output: `build/libs/shacraft-admission-client-0.1.0.jar`.
|
||||||
|
Publish that jar and the pinned Fabric API jar in the signed Minigames profile.
|
||||||
|
|
||||||
|
The native launcher supplies `SHACRAFT_ADMISSION_TICKET` and
|
||||||
|
`SHACRAFT_ADMISSION_PRIVATE_KEY` only to the final Java child environment.
|
||||||
|
The client accepts a CONFIGURATION payload on `shacraft_admission:challenge`
|
||||||
|
with three Minecraft UTF strings: server ID (16), nickname (16), nonce (43).
|
||||||
|
It verifies server `minigames`, the exact current game nickname and actual
|
||||||
|
socket `135.106.219.182:25568` (the previous IP remains accepted during migration), then signs once with the ephemeral Ed25519 key.
|
||||||
|
The response on `shacraft_admission:proof` contains ticket (43) and standard
|
||||||
|
Base64 signature (88). The transcript has no final newline:
|
||||||
|
|
||||||
|
```
|
||||||
|
shacraft-admission-v1
|
||||||
|
{ticket_id}
|
||||||
|
minigames
|
||||||
|
{mc_username}
|
||||||
|
{nonce}
|
||||||
|
```
|
||||||
|
|
||||||
|
The private key and website session never go onto the Minecraft wire. Errors
|
||||||
|
are redacted. A fresh game launch is needed for another connection after a
|
||||||
|
proof has been sent. `SHACRAFT_ADMISSION_ALLOW_LOOPBACK=1` additionally permits
|
||||||
|
literal loopback sockets for isolated tests; normal releases do not set it.
|
||||||
|
Paper must fail closed before world entry and reject unauthenticated duplicate
|
||||||
|
UUIDs before the vanilla duplicate-player eviction. The backend checks the
|
||||||
|
current shared aoc account access and atomically redeems the server-bound ticket.
|
||||||
|
|
||||||
|
Three unit tests cover exact signature binding, invalid/cross-server fields
|
||||||
|
and socket allowlisting. The unchanged production companion also passed actual Minecraft 26.2
|
||||||
|
configuration negotiation and entered a local Paper lobby with a synthetic
|
||||||
|
backend ticket; see [receipt](../docs/verification/minigames-fabric-2026-09-13.json).
|
||||||
|
The public server and other platforms still require their own rollout checks.
|
||||||
|
|
||||||
|
The client explicitly advertises its single challenge receiver with vanilla
|
||||||
|
`minecraft:register` at the start of configuration. Fabric normally waits for
|
||||||
|
the server's registration first, while Paper gates plugin sends on that client
|
||||||
|
advertisement. This bootstrap uses the pinned Fabric API's RegistrationPayload;
|
||||||
|
update it and rerun live negotiation checks when upgrading Fabric API. It is
|
||||||
|
queued after INIT so vanilla has switched outbound protocol to CONFIGURATION.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
plugins {
|
||||||
|
id 'net.fabricmc.fabric-loom' version "${loom_version}"
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories { mavenCentral() }
|
||||||
|
|
||||||
|
loom {
|
||||||
|
splitEnvironmentSourceSets()
|
||||||
|
mods {
|
||||||
|
'shacraft_admission' {
|
||||||
|
sourceSet sourceSets.main
|
||||||
|
sourceSet sourceSets.client
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
minecraft "com.mojang:minecraft:${project.minecraft_version}"
|
||||||
|
implementation "net.fabricmc:fabric-loader:${project.loader_version}"
|
||||||
|
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
|
||||||
|
testImplementation platform('org.junit:junit-bom:5.12.2')
|
||||||
|
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||||
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
|
}
|
||||||
|
|
||||||
|
processResources {
|
||||||
|
inputs.property 'version', project.version
|
||||||
|
filesMatching('fabric.mod.json') { expand version: project.version }
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(JavaCompile).configureEach { options.release = 25 }
|
||||||
|
java { toolchain.languageVersion = JavaLanguageVersion.of(25); withSourcesJar() }
|
||||||
|
test { useJUnitPlatform() }
|
||||||
|
|
||||||
|
// Pure HTTP/validation tests use the same client implementation without launching Minecraft.
|
||||||
|
sourceSets.test.compileClasspath += sourceSets.client.output
|
||||||
|
sourceSets.test.runtimeClasspath += sourceSets.client.output
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2G
|
||||||
|
org.gradle.parallel=false
|
||||||
|
org.gradle.configuration-cache=false
|
||||||
|
minecraft_version=26.2
|
||||||
|
loader_version=0.19.5
|
||||||
|
loom_version=1.17.20
|
||||||
|
fabric_api_version=0.160.0+26.2
|
||||||
|
version=0.1.0
|
||||||
|
group=ru.shacraft
|
||||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||||
|
distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f
|
||||||
|
networkTimeout=30000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
+248
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+82
@@ -0,0 +1,82 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
|
setlocal EnableExtensions
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||||
|
|
||||||
|
:exitWithErrorLevel
|
||||||
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
maven { url = 'https://maven.fabricmc.net/' }
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = 'shacraft-admission-client'
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package ru.shacraft.admission;
|
||||||
|
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import net.fabricmc.api.ClientModInitializer;
|
||||||
|
import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationNetworking;
|
||||||
|
import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationConnectionEvents;
|
||||||
|
import net.fabricmc.fabric.impl.networking.RegistrationPayload;
|
||||||
|
import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket;
|
||||||
|
import java.util.List;
|
||||||
|
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
|
||||||
|
/** The account session remains in the native launcher, never in Minecraft. */
|
||||||
|
public final class ClientAdmission implements ClientModInitializer {
|
||||||
|
private static final AtomicBoolean USED = new AtomicBoolean();
|
||||||
|
|
||||||
|
@Override public void onInitializeClient() {
|
||||||
|
PayloadTypeRegistry.clientboundConfiguration().register(AdmissionPayloads.Challenge.TYPE, AdmissionPayloads.Challenge.CODEC);
|
||||||
|
PayloadTypeRegistry.serverboundConfiguration().register(AdmissionPayloads.Proof.TYPE, AdmissionPayloads.Proof.CODEC);
|
||||||
|
ClientConfigurationNetworking.registerGlobalReceiver(AdmissionPayloads.Challenge.TYPE, ClientAdmission::challenge);
|
||||||
|
// Paper waits for vanilla channel advertisement, while Fabric normally waits
|
||||||
|
// for the server's registration first. Bootstrap our one fixed receiver.
|
||||||
|
// INIT runs in the listener constructor; schedule() queues until after vanilla
|
||||||
|
// has switched the outbound protocol from LOGIN to CONFIGURATION.
|
||||||
|
ClientConfigurationConnectionEvents.INIT.register((listener, client) -> client.schedule(() ->
|
||||||
|
listener.send(new ServerboundCustomPayloadPacket(new RegistrationPayload(
|
||||||
|
RegistrationPayload.REGISTER, List.of(AdmissionPayloads.Challenge.TYPE.id()))))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void challenge(AdmissionPayloads.Challenge challenge, ClientConfigurationNetworking.Context context) {
|
||||||
|
var connection = context.packetContext().orElseThrow(net.fabricmc.fabric.api.networking.v1.context.PacketContext.CONNECTION);
|
||||||
|
boolean loopback = "1".equals(System.getenv("SHACRAFT_ADMISSION_ALLOW_LOOPBACK"));
|
||||||
|
if (!(connection.getRemoteAddress() instanceof InetSocketAddress remote)
|
||||||
|
|| remote.getAddress() == null
|
||||||
|
|| !AdmissionProof.allowedTarget(remote.getAddress().getHostAddress(), remote.getPort(), loopback)
|
||||||
|
|| !AdmissionProof.SERVER_ID.equals(challenge.serverId())
|
||||||
|
|| !context.client().getUser().getName().equals(challenge.nickname())
|
||||||
|
|| !AdmissionProof.validOpaque(challenge.nonce())) {
|
||||||
|
deny(context); return;
|
||||||
|
}
|
||||||
|
String ticket = System.getenv("SHACRAFT_ADMISSION_TICKET");
|
||||||
|
String privateKey = System.getenv("SHACRAFT_ADMISSION_PRIVATE_KEY");
|
||||||
|
if (!AdmissionProof.validOpaque(ticket) || !USED.compareAndSet(false, true)) {
|
||||||
|
deny(context); return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String signature = AdmissionProof.sign(privateKey, ticket, challenge.serverId(), challenge.nickname(), challenge.nonce());
|
||||||
|
context.responseSender().sendPacket(new AdmissionPayloads.Proof(ticket, signature));
|
||||||
|
} catch (Exception invalidKey) { deny(context); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void deny(ClientConfigurationNetworking.Context context) {
|
||||||
|
context.responseSender().disconnect(Component.literal(
|
||||||
|
"Не удалось подтвердить вход ShaCraft. Закройте игру и запустите её заново через ShaCraft Launcher."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package ru.shacraft.admission;
|
||||||
|
|
||||||
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
public final class AdmissionPayloads {
|
||||||
|
private AdmissionPayloads() {}
|
||||||
|
|
||||||
|
public record Challenge(String serverId, String nickname, String nonce) implements CustomPacketPayload {
|
||||||
|
public static final Type<Challenge> TYPE = new Type<>(Identifier.fromNamespaceAndPath("shacraft_admission", "challenge"));
|
||||||
|
public static final StreamCodec<FriendlyByteBuf, Challenge> CODEC = StreamCodec.of(
|
||||||
|
(buffer, value) -> { buffer.writeUtf(value.serverId, 16); buffer.writeUtf(value.nickname, 16); buffer.writeUtf(value.nonce, 43); },
|
||||||
|
buffer -> new Challenge(buffer.readUtf(16), buffer.readUtf(16), buffer.readUtf(43)));
|
||||||
|
@Override public Type<Challenge> type() { return TYPE; }
|
||||||
|
@Override public String toString() { return "AdmissionChallenge[redacted]"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Proof(String ticket, String signature) implements CustomPacketPayload {
|
||||||
|
public static final Type<Proof> TYPE = new Type<>(Identifier.fromNamespaceAndPath("shacraft_admission", "proof"));
|
||||||
|
public static final StreamCodec<FriendlyByteBuf, Proof> CODEC = StreamCodec.of(
|
||||||
|
(buffer, value) -> { buffer.writeUtf(value.ticket, 43); buffer.writeUtf(value.signature, 88); },
|
||||||
|
buffer -> new Proof(buffer.readUtf(43), buffer.readUtf(88)));
|
||||||
|
@Override public Type<Proof> type() { return TYPE; }
|
||||||
|
@Override public String toString() { return "AdmissionProof[redacted]"; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package ru.shacraft.admission;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.KeyFactory;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.Signature;
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/** The only client credential is an ephemeral private key supplied by the launcher. */
|
||||||
|
public final class AdmissionProof {
|
||||||
|
public static final String SERVER_ID = "minigames";
|
||||||
|
private static final Pattern OPAQUE = Pattern.compile("[A-Za-z0-9_-]{43}");
|
||||||
|
private static final Pattern NICKNAME = Pattern.compile("[A-Za-z0-9_]{3,16}");
|
||||||
|
|
||||||
|
private AdmissionProof() {}
|
||||||
|
|
||||||
|
public static boolean validOpaque(String value) {
|
||||||
|
return value != null && OPAQUE.matcher(value).matches();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] transcript(String ticket, String serverId, String nickname, String nonce) {
|
||||||
|
if (!validOpaque(ticket) || !validOpaque(nonce) || !SERVER_ID.equals(serverId)
|
||||||
|
|| nickname == null || !NICKNAME.matcher(nickname).matches()) {
|
||||||
|
throw new IllegalArgumentException("Invalid admission challenge");
|
||||||
|
}
|
||||||
|
return ("shacraft-admission-v1\n" + ticket + "\n" + serverId + "\n"
|
||||||
|
+ nickname + "\n" + nonce).getBytes(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String sign(String encodedPrivateKey, String ticket, String serverId,
|
||||||
|
String nickname, String nonce) throws Exception {
|
||||||
|
if (encodedPrivateKey == null || encodedPrivateKey.length() > 256) {
|
||||||
|
throw new IllegalArgumentException("Missing admission key");
|
||||||
|
}
|
||||||
|
byte[] encoded = Base64.getDecoder().decode(encodedPrivateKey);
|
||||||
|
try {
|
||||||
|
PrivateKey key = KeyFactory.getInstance("Ed25519")
|
||||||
|
.generatePrivate(new PKCS8EncodedKeySpec(encoded));
|
||||||
|
Signature signer = Signature.getInstance("Ed25519");
|
||||||
|
signer.initSign(key);
|
||||||
|
signer.update(transcript(ticket, serverId, nickname, nonce));
|
||||||
|
return Base64.getEncoder().encodeToString(signer.sign());
|
||||||
|
} finally {
|
||||||
|
java.util.Arrays.fill(encoded, (byte) 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean validSignature(String value) {
|
||||||
|
if (value == null || value.length() != 88) return false;
|
||||||
|
try {
|
||||||
|
byte[] decoded = Base64.getDecoder().decode(value);
|
||||||
|
return decoded.length == 64 && Base64.getEncoder().encodeToString(decoded).equals(value);
|
||||||
|
} catch (IllegalArgumentException invalid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean allowedTarget(String host, int port, boolean allowLoopback) {
|
||||||
|
if (host == null) return false;
|
||||||
|
if (allowLoopback && (host.equals("127.0.0.1") || host.equals("::1") || host.equals("[::1]") || host.equals("0:0:0:0:0:0:0:1"))) {
|
||||||
|
return port > 0 && port <= 65535;
|
||||||
|
}
|
||||||
|
return port == 25568 && (host.equalsIgnoreCase("shacraft.ru") || host.equals("135.106.154.86") || host.equals("135.106.219.182"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "shacraft_admission",
|
||||||
|
"version": "${version}",
|
||||||
|
"name": "ShaCraft Minigames Admission",
|
||||||
|
"description": "Account-bound admission to ShaCraft Minigames.",
|
||||||
|
"environment": "client",
|
||||||
|
"entrypoints": { "client": ["ru.shacraft.admission.ClientAdmission"] },
|
||||||
|
"depends": { "fabricloader": ">=0.19.5", "minecraft": "26.2", "java": ">=25", "fabric-networking-api-v1": "*" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package ru.shacraft.admission;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.Signature;
|
||||||
|
import java.util.Base64;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class AdmissionProofTest {
|
||||||
|
private static final String TICKET = "A".repeat(43), NONCE = "B".repeat(43);
|
||||||
|
@Test void signsExactServerBoundTranscript() throws Exception {
|
||||||
|
var pair=KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
|
||||||
|
String signed=AdmissionProof.sign(Base64.getEncoder().encodeToString(pair.getPrivate().getEncoded()), TICKET,"minigames","Pilot_1",NONCE);
|
||||||
|
var verifier=Signature.getInstance("Ed25519"); verifier.initVerify(pair.getPublic());
|
||||||
|
verifier.update(("shacraft-admission-v1\n"+TICKET+"\nminigames\nPilot_1\n"+NONCE).getBytes(StandardCharsets.UTF_8));
|
||||||
|
assertTrue(verifier.verify(Base64.getDecoder().decode(signed)));
|
||||||
|
verifier.update(AdmissionProof.transcript(TICKET,"minigames","Other",NONCE));
|
||||||
|
assertFalse(verifier.verify(Base64.getDecoder().decode(signed)));
|
||||||
|
}
|
||||||
|
@Test void rejectsCrossServerAndMalformedFields() {
|
||||||
|
for (String[] v:new String[][]{{TICKET,"aoc","Pilot",NONCE},{TICKET,"minigames","Bad\nName",NONCE},{"bad","minigames","Pilot",NONCE},{TICKET,"minigames","Pilot","bad"}})
|
||||||
|
assertThrows(IllegalArgumentException.class,()->AdmissionProof.transcript(v[0],v[1],v[2],v[3]));
|
||||||
|
}
|
||||||
|
@Test void trustsOnlyMinigamesSocketAndExplicitLocalTests() {
|
||||||
|
assertTrue(AdmissionProof.allowedTarget("shacraft.ru",25568,false));
|
||||||
|
assertTrue(AdmissionProof.allowedTarget("135.106.219.182",25568,false));
|
||||||
|
assertTrue(AdmissionProof.allowedTarget("135.106.154.86",25568,false));
|
||||||
|
assertFalse(AdmissionProof.allowedTarget("135.106.219.183",25568,false));
|
||||||
|
assertFalse(AdmissionProof.allowedTarget("135.106.219.182",25567,false));
|
||||||
|
assertFalse(AdmissionProof.allowedTarget("135.106.154.86",25567,false));
|
||||||
|
assertFalse(AdmissionProof.allowedTarget("127.0.0.1",25568,false));
|
||||||
|
assertTrue(AdmissionProof.allowedTarget("127.0.0.1",25570,true));
|
||||||
|
assertFalse(AdmissionProof.allowedTarget("attacker.invalid",25568,true));
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-19
@@ -91,24 +91,20 @@ 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
|
one of the ones above (or add a new hardcoded constant following the same
|
||||||
pattern) rather than accepting a URL from anywhere else.
|
pattern) rather than accepting a URL from anywhere else.
|
||||||
|
|
||||||
|
## 5. Fabric (`fabric.rs`, Minigames)
|
||||||
|
|
||||||
## Generated NeoForge artifacts and Java selection
|
Metadata comes only from `https://meta.fabricmc.net/v2/versions/loader/` for
|
||||||
|
manifest-selected, validated version identifiers. Profile ID, parent and
|
||||||
|
KnotClient main class must match. At most 512 KiB metadata and 32 libraries
|
||||||
|
are accepted. Library URLs are constructed only below
|
||||||
|
`https://maven.fabricmc.net/` from portable three-part Maven coordinates;
|
||||||
|
other metadata origins are rejected. Libraries require a 40-hex SHA-1 and a
|
||||||
|
positive size up to 64 MiB. When Fabric metadata omits either for its loader
|
||||||
|
jar, the fixed Maven's `.sha1` sidecar and HEAD provide them. Existing verified
|
||||||
|
downloads perform the hash/size check and atomic rename. The ShaCraft signed
|
||||||
|
manifest cannot select Fabric metadata URLs, repositories or launch targets.
|
||||||
|
|
||||||
NeoForge 21.1.248's actual installer does not publish processor-output hashes.
|
Minecraft 26.2's official version metadata requires Java 25. `runtime.rs`
|
||||||
The launcher therefore validates the installer SHA-256, extracts its expected
|
already provisions a separate Adoptium Java 25 runtime without changing the
|
||||||
version JSON and a scoped processor recipe, verifies the vanilla input against
|
Aeronautics Java 21 runtime. Client companion mods and Fabric API are separately
|
||||||
Mojang, and runs that installer in a fresh staging tree. Generated JARs must
|
approved ShaCraft managed files in the signed Minigames profile.
|
||||||
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.
|
|
||||||
|
|||||||
+282
-167
@@ -2,37 +2,43 @@
|
|||||||
|
|
||||||
## Current capability
|
## Current capability
|
||||||
|
|
||||||
The launcher persists local settings, synchronises Aeronautics mod/config
|
The launcher persists local settings, synchronises profile mod/config
|
||||||
files from the signed ShaCraft v2 manifest, installs the exact Minecraft +
|
files from the signed ShaCraft v2 manifest, installs the exact Minecraft +
|
||||||
NeoForge version the manifest specifies, and launches the game. A player
|
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
|
signs in with the same local ShaCraft account used on the website. The current
|
||||||
identity for ordinary Play is derived only from that account's verified Aeronautics nickname;
|
admission implementation claims a free nickname for that account and requests
|
||||||
the legacy editable nickname setting is not trusted at launch.
|
a one-use permission immediately before launching Java. The game identity
|
||||||
|
comes only from the canonical Aeronautics nickname in that permission; the
|
||||||
|
legacy editable nickname setting is not trusted at launch.
|
||||||
|
|
||||||
|
The backend and admission mod were deployed to Aeronautics on 2026-09-10;
|
||||||
|
the server is healthy with whitelist enforcement retained. Real isolated
|
||||||
|
NeoForge connections verified successful admission, absent/replayed proof
|
||||||
|
rejection and protection of an online player from duplicate login. A public
|
||||||
|
production connection without the mod was rejected before world entry.
|
||||||
|
This does not certify a full cold installation or Windows/macOS operation.
|
||||||
|
|
||||||
The interface also shows a live Aeronautics player count from the fixed,
|
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:
|
read-only `https://shacraft.ru/api/online/aoc` endpoint. It is display-only:
|
||||||
the result never controls files, versions, URLs, or the launch command.
|
the result never controls files, versions, URLs, or the launch command.
|
||||||
|
|
||||||
The launcher also checks signed GitHub releases for its own updates. AppImage,
|
Version 0.1.3 adds signed application updates, separate from modpack sync.
|
||||||
Windows x64 installers and native Intel/Apple Silicon macOS app bundles use
|
Version 0.1.4 adds installed deb updates with system administrator confirmation.
|
||||||
explicit install-and-restart; Debian packages use manual package management.
|
The first AppImage upgrade from 0.1.2 and deb upgrade from 0.1.3 are manual.
|
||||||
Production publication and OS signing/notarization require operator setup;
|
Windows/macOS 0.1.5 packages are published; actual desktop installation tests remain pending.
|
||||||
see `updater-release.md`. Version 0.1.1 has no updater and must be upgraded
|
Not yet implemented: a user-selectable profile directory and a "reset managed
|
||||||
manually once. User-selectable profile directories and managed-only reset
|
files only" recovery action. OS code signing/notarization is separate from the
|
||||||
remain unimplemented.
|
updater signatures and is not certified by this implementation.
|
||||||
|
|
||||||
## Data flow
|
## Data flow
|
||||||
|
|
||||||
One verified ShaCraft snapshot is fetched inside the installation lock for
|
Managed files, game installation and account admission meet at Java spawn:
|
||||||
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
|
```text
|
||||||
ShaCraft manifest (mods/config + which MC/loader/Java version to use)
|
ShaCraft manifest (mods/config + which MC/loader/Java version to use)
|
||||||
signed-manifest endpoint -> Ed25519 verification (remote.rs)
|
signed-manifest endpoint -> Ed25519 verification (remote.rs)
|
||||||
-> manifest schema + URL/path validation (manifest.rs)
|
-> manifest schema + URL/path validation (manifest.rs)
|
||||||
-> inventory reconciliation, verified staging, durable apply journal (profile.rs)
|
-> temporary download, SHA-256 verification, atomic replacement (profile.rs)
|
||||||
|
|
||||||
Game itself (never controlled by the manifest above)
|
Game itself (never controlled by the manifest above)
|
||||||
Mojang version manifest -> SHA-1-verified version JSON (mojang.rs)
|
Mojang version manifest -> SHA-1-verified version JSON (mojang.rs)
|
||||||
@@ -40,9 +46,14 @@ Game itself (never controlled by the manifest above)
|
|||||||
-> NeoForge's own installer, run headlessly (neoforge.rs)
|
-> NeoForge's own installer, run headlessly (neoforge.rs)
|
||||||
-> generic inheritsFrom merge of the two version JSONs (mojang.rs)
|
-> generic inheritsFrom merge of the two version JSONs (mojang.rs)
|
||||||
-> SHA-1-verified merged libraries + platform natives (mojang.rs)
|
-> SHA-1-verified merged libraries + platform natives (mojang.rs)
|
||||||
-> verified ShaCraft account link (shacraft_account.rs)
|
|
||||||
-> deterministic offline UUID for the linked nickname (session.rs)
|
Admission (fixed ShaCraft account API; never controlled by a manifest)
|
||||||
-> java process spawned with the merged classpath/args (launch.rs)
|
native website session -> direct free-nickname claim or admin migration
|
||||||
|
-> after installation: OS CSPRNG -> ephemeral Ed25519 key (admission.rs)
|
||||||
|
-> public key + bearer session -> one-use ticket for canonical aoc nickname
|
||||||
|
-> deterministic offline UUID for that nickname (session.rs)
|
||||||
|
-> Java with merged classpath/args + child-only ticket/private-key environment
|
||||||
|
-> client mod signs server challenge; server validates before world entry
|
||||||
```
|
```
|
||||||
|
|
||||||
Profiles (ShaCraft-managed mods/config, and the player's own worlds/
|
Profiles (ShaCraft-managed mods/config, and the player's own worlds/
|
||||||
@@ -52,7 +63,8 @@ install (versions/libraries/assets/runtime, reused across profiles that
|
|||||||
target the same Minecraft version) lives at `app_data_dir()/game`. Settings
|
target the same Minecraft version) lives at `app_data_dir()/game`. Settings
|
||||||
live at `app_data_dir()/settings.json`, and the revocable ShaCraft session at
|
live at `app_data_dir()/settings.json`, and the revocable ShaCraft session at
|
||||||
`app_data_dir()/shacraft-session` (mode 600 on Unix). Passwords are never
|
`app_data_dir()/shacraft-session` (mode 600 on Unix). Passwords are never
|
||||||
written to disk. None of these should be assumed to
|
written to disk. The admission private key and ticket are ephemeral native
|
||||||
|
values and are not persisted. None of these directories should be assumed to
|
||||||
be the system `.minecraft` directory.
|
be the system `.minecraft` directory.
|
||||||
|
|
||||||
## Aeronautics contract
|
## Aeronautics contract
|
||||||
@@ -67,18 +79,81 @@ be the system `.minecraft` directory.
|
|||||||
- ShaCraft download files: HTTPS only, exact hosts `shacraft.ru` and
|
- ShaCraft download files: HTTPS only, exact hosts `shacraft.ru` and
|
||||||
`cdn.shacraft.ru`.
|
`cdn.shacraft.ru`.
|
||||||
- Account API origin: fixed `https://shacraft.ru`; redirects are rejected.
|
- Account API origin: fixed `https://shacraft.ru`; redirects are rejected.
|
||||||
- Launch identity: the most recently verified `aoc` nickname returned by the
|
- Launch identity: the canonical `aoc` nickname returned with the admission
|
||||||
authenticated account API. Local nickname edits cannot select an identity.
|
ticket. Local nickname edits cannot select an identity.
|
||||||
|
|
||||||
|
## Admission contract (implementation: 2026-09-10)
|
||||||
|
|
||||||
|
Both endpoints use the fixed account API origin, HTTPS without redirects and
|
||||||
|
the native website session as bearer authentication. The session is never
|
||||||
|
passed to the client mod or Java process.
|
||||||
|
|
||||||
|
`POST /api/launcher/v2/admission/nickname` accepts
|
||||||
|
`{server_id: "aoc", mc_username: "Chosen_Name"}` and returns the existing
|
||||||
|
account shape `{username, links}`. The backend atomically assigns a free name
|
||||||
|
to that account. Existing player names remain reserved and require explicit
|
||||||
|
administrator migration. The launcher no longer asks the player to enter the
|
||||||
|
game to complete this claim. Legacy challenge IPC remains available to older
|
||||||
|
flows, but an admission-enabled backend must block those legacy endpoints
|
||||||
|
from bypassing reserved-name ownership.
|
||||||
|
|
||||||
|
After installation completes, the native launcher generates a fresh Ed25519
|
||||||
|
key using the OS CSPRNG and calls
|
||||||
|
`POST /api/launcher/v2/admission/tickets` with
|
||||||
|
`{server_id: "aoc", public_key: "<standard base64 raw 32-byte public key>"}`.
|
||||||
|
The response is `{ticket_id, mc_username, server_id, expires_in_seconds}`.
|
||||||
|
The native boundary requires a canonical 43-character base64url ticket ID,
|
||||||
|
an ASCII Minecraft nickname of 3–16 letters/digits/underscores, server `aoc`
|
||||||
|
and a positive lifetime of at most 600 seconds. The backend checks the current
|
||||||
|
account session, bound nickname and server access before issuing it.
|
||||||
|
|
||||||
|
`admission.rs` has no secret-bearing `Debug` or `Serialize` implementation.
|
||||||
|
Only the final Java child's environment receives:
|
||||||
|
|
||||||
|
- `SHACRAFT_ADMISSION_TICKET`: the one-use ticket ID.
|
||||||
|
- `SHACRAFT_ADMISSION_PRIVATE_KEY`: standard base64 of the Ed25519 PKCS#8
|
||||||
|
private-key-only DER representation accepted by Java's `KeyFactory`.
|
||||||
|
|
||||||
|
No global environment mutation, webview/IPC payload, argument substitution,
|
||||||
|
JVM argfile, settings file or log stores these values. The account operation
|
||||||
|
permit remains held from issuance through spawn so local account switching
|
||||||
|
or logout cannot race the handoff. Missing, disabled or invalid admission
|
||||||
|
responses fail the launch with a visible error; there is no legacy-name or
|
||||||
|
unsigned fallback. In this first version, a consumed or expired ticket needs
|
||||||
|
a fresh game launch from the launcher; transparent in-game reconnect is not
|
||||||
|
implemented.
|
||||||
|
|
||||||
|
The client mod proves possession of the ephemeral private key by signing the
|
||||||
|
server's challenge. The server mod gates world entry on successful backend
|
||||||
|
verification, including one-use consumption and current account/link/access
|
||||||
|
checks. Whitelist enforcement is retained. The 2026-09-10 Aeronautics rollout
|
||||||
|
replaced its separate LoginSystem `/register` and `/login` flow; other servers
|
||||||
|
keep their existing authentication. Old launchers without admission proof
|
||||||
|
cannot join Aeronautics. A required server-only Mixin rejects a duplicate
|
||||||
|
online UUID before vanilla can disconnect the existing player. The client pins
|
||||||
|
the actual game socket to `135.106.154.86:25567`; changing that address requires
|
||||||
|
an explicit mod update. Server source and rollout records live in
|
||||||
|
`/root/shacraft/services/admission-mod` and `docs/admission-2026-09-10.md` on
|
||||||
|
the ShaCraft host.
|
||||||
|
|
||||||
|
This protocol prevents entry without the account's current permission. It
|
||||||
|
does not attest that an original launcher or game binary is unmodified:
|
||||||
|
software running as the same user can read its own process environment, and
|
||||||
|
a compatible client can implement the protocol. Never replace account-bound
|
||||||
|
proof with a shared key embedded in distributed binaries.
|
||||||
|
|
||||||
## Planned but not implemented
|
## Planned but not implemented
|
||||||
|
|
||||||
1. User-selectable profile directory and structured launcher logs.
|
1. User-selectable profile directory and structured launcher logs.
|
||||||
2. "Reset managed files only" recovery action that doesn't touch player
|
2. "Reset managed files only" recovery action that doesn't touch player
|
||||||
worlds/screenshots/resourcepacks.
|
worlds/screenshots/resourcepacks.
|
||||||
3. Production release credentials/protected environments and OS beta validation.
|
3. Windows Authenticode/macOS signing-notarization and cross-platform release
|
||||||
|
installation testing.
|
||||||
4. Cancellation, structured logs and a full cold-install/recovery beta on
|
4. Cancellation, structured logs and a full cold-install/recovery beta on
|
||||||
every target OS. Install progress reports bytes or installer work counts
|
every target OS. Install progress reports bytes or installer work counts
|
||||||
depending on the stage; these units are not interchangeable.
|
depending on the stage; these units are not interchangeable.
|
||||||
|
5. Transparent reconnect after the admission ticket has been consumed or
|
||||||
|
expired. The current implementation requires a new launch.
|
||||||
|
|
||||||
Do not represent these as completed features in UI or release notes.
|
Do not represent these as completed features in UI or release notes.
|
||||||
|
|
||||||
@@ -95,10 +170,7 @@ Browser preview cannot install/launch and does not simulate download progress.
|
|||||||
Rust `lib.rs` registers commands from `commands/`. Installation and account
|
Rust `lib.rs` registers commands from `commands/`. Installation and account
|
||||||
permits in `operations.rs` stay owned by blocking workers until completion.
|
permits in `operations.rs` stay owned by blocking workers until completion.
|
||||||
ShaCraft sessions have a separate gate from the retained Microsoft module.
|
ShaCraft sessions have a separate gate from the retained Microsoft module.
|
||||||
An additional OS file lock in `installation_lock.rs` covers all profiles and
|
These are process-local guards, not cross-process locks or cancellation.
|
||||||
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
|
`storage.rs` provides unique temporary files and atomic replacement; Unix
|
||||||
session files are created owner-only. Windows keeps a recoverable replacement
|
session files are created owner-only. Windows keeps a recoverable replacement
|
||||||
fallback if the OS refuses direct replacement. `trusted_http.rs` constrains provider
|
fallback if the OS refuses direct replacement. `trusted_http.rs` constrains provider
|
||||||
@@ -106,158 +178,201 @@ URLs and redirects. Manifest profile identity, size, signature, portable
|
|||||||
paths and existing symlinks are checked before managed file writes.
|
paths and existing symlinks are checked before managed file writes.
|
||||||
Hostile same-user TOCTOU is outside this protection; it is not an OS sandbox.
|
Hostile same-user TOCTOU is outside this protection; it is not an OS sandbox.
|
||||||
|
|
||||||
|
## Signed application updates (0.1.3)
|
||||||
|
|
||||||
|
`updater.rs` accepts only the fixed HTTPS feed
|
||||||
|
`https://shacraft.ru/launcher/updates/stable.json`. A dedicated embedded Tauri
|
||||||
|
public key authenticates both the metadata payload and the selected package.
|
||||||
|
The signed metadata binds the plain stable version, release notes, date and
|
||||||
|
platform URLs. Artifact URLs are confined to the matching version directory
|
||||||
|
under `https://shacraft.ru/downloads/shacraft-launcher/`. The IPC never accepts
|
||||||
|
a URL, key, destination path or replacement executable from the webview.
|
||||||
|
|
||||||
|
Metadata is downloaded once, with a 192 KiB envelope/64 KiB payload bound;
|
||||||
|
packages are capped at 256 MiB. Redirects and version downgrades are rejected.
|
||||||
|
The small vendored Tauri 2.11.0 `check_metadata` patch constructs its update
|
||||||
|
object without another HTTP request. Linux AppImage installation uses a
|
||||||
|
same-directory temporary file, signature verification, preserved permissions,
|
||||||
|
atomic rename and file/directory fsync. Windows/macOS retain Tauri's platform
|
||||||
|
installers. Unsupported Linux formats show manual installation instructions.
|
||||||
|
|
||||||
|
For installed deb packages, 0.1.4 selects only `linux-x86_64-deb`. The feed also
|
||||||
|
retains the identical legacy `linux-x86_64` and explicit `linux-x86_64-appimage`
|
||||||
|
AppImage entries so installed 0.1.3 readers remain compatible. Remote metadata
|
||||||
|
cannot switch a deb installation into an AppImage installation.
|
||||||
|
|
||||||
|
`deb_updater.rs` checks root ownership of the installed executable and its
|
||||||
|
parents and dpkg's ownership/version record. One `pkexec` invocation starts an
|
||||||
|
early non-GUI mode of `/usr/bin/shacraft-launcher`; no password is collected by
|
||||||
|
the launcher and no fallback prompt runs after cancellation. Bounded stdin
|
||||||
|
framing carries the signed envelope and package bytes, never user paths.
|
||||||
|
The helper authenticates both again as root, checks exact package identity
|
||||||
|
`sha-craft-launcher`, architecture and version, stages the package under a
|
||||||
|
root-only temporary directory and invokes the fixed dpkg installer. An explicit
|
||||||
|
`--refuse-downgrade` protects against another installation winning the version
|
||||||
|
race. Failed/partial package transactions require honest system-package recovery;
|
||||||
|
they are not reported as completed or automatically retried. Restart launches
|
||||||
|
the fixed installed executable even after dpkg replaces the running inode.
|
||||||
|
|
||||||
|
The native updater holds installation, account and game permits while installing
|
||||||
|
and until restart. The game permit remains held until the launched Java child
|
||||||
|
exits. These guards cover this launcher process, not other launcher instances.
|
||||||
|
Startup checks never silently install; settings expose check, install, progress,
|
||||||
|
errors and restart. A failed check does not prevent using the installed version.
|
||||||
|
|
||||||
|
The private updater key stays on the operator's computer. Normal CI builds are
|
||||||
|
explicitly unsigned; reviewed release artifacts and metadata are signed locally
|
||||||
|
and published only after signature/hash verification. The Caddy feed route uses
|
||||||
|
`Cache-Control: no-store`. See [launcher-updates.md](launcher-updates.md) for
|
||||||
|
the envelope contract, publisher commands and recovery constraints.
|
||||||
|
|
||||||
## Verification and distribution
|
## Verification and distribution
|
||||||
|
|
||||||
`npm test` covers asynchronous helpers and state transitions;
|
`npm test` covers asynchronous helpers and state transitions;
|
||||||
`npm run build` runs strict TypeScript before Vite. `cargo test --locked`
|
`npm run build` runs strict TypeScript before Vite. `cargo test --locked`
|
||||||
covers native policy and storage. Push/PR CI repeats checks on Linux. Ubuntu 22.04
|
covers native policy and storage. Push/PR CI repeats checks on Linux.
|
||||||
CI installs desktop dependencies from the runner main distro source list only,
|
The package workflow runs on main pushes or manually and builds Windows
|
||||||
without indexing unrelated vendor repositories. APT signature/hash checks remain
|
x64, Linux x64 and both macOS architectures with named artifacts.
|
||||||
mandatory, and any distro index failure stops installation.
|
CI packages are unsigned build artifacts. Signed updater publication is a
|
||||||
The package workflow builds and checks Windows x64, Linux x64 and both macOS
|
separate local operator step. Native cold-install and launch tests are required
|
||||||
architectures with disposable test signing keys. These artifacts cannot be
|
before calling a platform release-ready.
|
||||||
published as production updater releases. Separate protected workflows assemble
|
|
||||||
a complete signed release as a draft, re-verify its assets and only then publish.
|
The local admission checkpoint passed 64 Rust tests (5 live tests ignored),
|
||||||
Native cold-install, real self-update and launch tests are required before
|
22 UI unit tests, TypeScript/Vite build and a Linux x86-64 release build with
|
||||||
calling a platform release-ready. CI package builds do not prove those flows.
|
`npm run tauri:build -- --no-bundle`. Six browser scenarios with mocked Tauri
|
||||||
|
IPC covered invalid nicknames, deleted sessions, reserved-name errors,
|
||||||
|
successful claims, duplicate clicks and a session revoked during a claim.
|
||||||
|
They also checked modal feedback, Escape preserving the settings drawer and
|
||||||
|
the absence of legacy link polling. These checks used no production account
|
||||||
|
or real Minecraft connection. The Linux output is a dynamically linked
|
||||||
|
binary, not evidence of Windows/macOS support testing.
|
||||||
|
|
||||||
|
Version 0.1.2 also produced unsigned Linux amd64 AppImage and deb packages with
|
||||||
|
`npm run tauri:build -- --bundles appimage,deb`. AppImage extraction and the
|
||||||
|
deb's version/architecture metadata were checked without running the app or
|
||||||
|
installing the package. The build host was Ubuntu 26.04; do not claim support
|
||||||
|
for older Ubuntu releases from this build. For local AppImage packaging,
|
||||||
|
linuxdeploy's GTK plugin needs `librsvg-2.0.pc` from the matching `librsvg2-dev`
|
||||||
|
package. Extracting that package into a temporary build directory and setting
|
||||||
|
`PKG_CONFIG_PATH` supplied the missing metadata without changing host packages.
|
||||||
|
|
||||||
|
The 0.1.3 updater checkpoint passed 76 native tests (6 live tests ignored),
|
||||||
|
28 UI tests, 11 publisher tests with real minisign and TypeScript/Vite build.
|
||||||
|
Nine browser scenarios used mocked IPC. The signed Linux AppImage/deb were
|
||||||
|
published on 2026-09-10 with a signed stable feed; feed bytes, signatures and
|
||||||
|
public HTTPS responses were verified. A separately invoked live native test
|
||||||
|
downloaded the production release, rejected corrupted bytes without changing
|
||||||
|
the old file, atomically updated a temporary copy of 0.1.2 and compared hashes.
|
||||||
|
The original source AppImage was retained. The installed 0.1.3 AppImage was
|
||||||
|
then started from `~/Applications` and its captured runtime paths verified.
|
||||||
|
This is not a full GUI update/restart cycle or a Windows/macOS installation test.
|
||||||
|
The Linux build host remains Ubuntu 26.04.
|
||||||
|
|
||||||
|
The 0.1.4 checkpoint passed 84 native tests (6 ignored), 32 UI tests and 18
|
||||||
|
publisher tests; 12 browser scenarios use mocked IPC. In a disposable Ubuntu
|
||||||
|
26.04 Docker container without network or production mounts, the actual signed
|
||||||
|
deb helper passed 10 scenarios: unprivileged invocation, truncated/trailing
|
||||||
|
input, damaged metadata/package, dpkg lock, unsafe temporary directory,
|
||||||
|
successful installation, replay and downgrade refusal. The fixture installed
|
||||||
|
the genuine old 0.1.3 package and bootstrapped the new verifier binary over its
|
||||||
|
package record; it then installed the genuine signed 0.1.4. It did not relabel
|
||||||
|
signed versions. Dpkg reported 0.1.4 and a fixture profile marker survived.
|
||||||
|
This tests the elevated helper and dpkg, not a real desktop PolicyKit dialog.
|
||||||
|
Cancellation/error rendering is covered by unit/browser scenarios. Published
|
||||||
|
metadata and deb bytes match the locally verified files; both website download
|
||||||
|
buttons target 0.1.4. No user host package installation was performed for QA.
|
||||||
|
The live native AppImage smoke also passed against the published 0.1.4 feed,
|
||||||
|
including corruption rejection and replacement of only a temporary source copy.
|
||||||
|
|
||||||
|
|
||||||
## Reconciliation and recovery
|
## Cross-platform release 0.1.5 (2026-09-10)
|
||||||
|
|
||||||
`profiles/.<profile-id>.shacraft-state/` is outside the payload root. It stores
|
Published Windows x64 EXE/MSI, macOS aarch64 and x86_64 DMG/app.tar.gz,
|
||||||
`inventory.json`, `pending.json`, transaction staging/backups/receipts and
|
and Linux x64 AppImage/DEB on https://shacraft.ru/help#launcher. The signed
|
||||||
explicit legacy-mod backups. The inventory records only files the launcher
|
stable feed includes all platforms plus the legacy/exact Linux aliases.
|
||||||
actually wrote, with size/hash/policy and signed snapshot digest. An existing
|
CI source b43fc610c43a9ec9f5f3ffce601de9604670ff8a, successful Actions run
|
||||||
file that already matches is usable, but is not silently claimed as owned.
|
https://github.com/emil28092005/shacraft-launcher/actions/runs/34512683651.
|
||||||
Seed files are never owned for later removal.
|
An initial non-Linux borrow/move compilation error in updater target selection
|
||||||
|
was fixed before the final build. Native tests: Windows70, macOS74 per arch,
|
||||||
|
Linux84; UI tests pass on all four runners. Local checks verify macOS bundle
|
||||||
|
version/CPU type, Linux package identity and every artifact signature. Public
|
||||||
|
HTTPS downloads of all eight artifacts match local SHA-256. Website418 tests
|
||||||
|
plus48 subtests pass; only backend recreated, game containers unchanged.
|
||||||
|
Updater signatures use the existing operator-held key, never uploaded to CI or
|
||||||
|
server. Windows installers have no Authenticode signature; macOS is not Apple
|
||||||
|
notarized. Native CI tests and packaging do not certify full Minecraft installs
|
||||||
|
or desktop updater/restart behavior on Windows/macOS. Older unsupported clients
|
||||||
|
need a manual installation of the current release. Previous releases immutable.
|
||||||
|
|
||||||
All required downloads are staged and verified before apply. A write-ahead
|
The live native updater test passed against the published 0.1.5 feed: verified download, corrupted-byte rejection and replacement of only a temporary AppImage copy. The user-installed launcher was not modified.
|
||||||
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/<version>.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 <id> <code>`.
|
|
||||||
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)
|
## Admission client menu 0.1.1 (2026-09-11)
|
||||||
|
|
||||||
`updater/protocol.rs` pins `github.com/emil28092005/shacraft-launcher/releases`.
|
The signed Aeronautics payload now contains admission mod 0.1.1 at the existing
|
||||||
The exact raw `latest.json` response is verified using its detached minisign
|
managed path `mods/shacraft-admission-0.1.0.jar` to prevent duplicate mod IDs on
|
||||||
signature and the committed updater public key before versions, URLs or notes
|
upgrade. SHA-256: `faa9ae13cb0f2d93c03dae26ab36ae20d3fb6b66c89c09254b16808d6b183f89`.
|
||||||
are parsed. This key is independent of ShaCraft's Ed25519 mod manifest. Signed
|
From the title screen (and vanilla safety acknowledgement), Multiplayer connects
|
||||||
metadata binds a stable version and tag to an exact set of four platform and
|
to fixed `135.106.154.86:25567`. Cancel/errors return to TitleScreen; transitions
|
||||||
four manual package descriptors; every descriptor has an exact repository/tag/
|
from other screens do not auto-connect. Client-only registration, protocol 1 and
|
||||||
filename, size, SHA-256 and Tauri signature. HTTPS redirects are restricted to
|
one-use admission remain unchanged. Running game server was not restarted.
|
||||||
that repository and GitHub's release asset CDN. Stable downgrades, unknown
|
Linux Java 21 build and 8 mod tests pass. An opt-in native live test verifies
|
||||||
platforms, missing signatures and incomplete metadata fail closed.
|
signed-manifest retrieval and download/repair/restoration of the admission jar
|
||||||
|
only in a temporary directory. Mac 0.1.5 connection failure remains unclassified
|
||||||
|
pending exact error/log; this is not a verified macOS fix or desktop UI test.
|
||||||
|
|
||||||
Only native commands check, download, install and open the fixed releases page.
|
## Minigames alongside Aeronautics (2026-09-13)
|
||||||
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
|
The new native allowlist adds profile `minigames` at
|
||||||
not install automatically. The settings drawer shows installed/available
|
`https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest` and
|
||||||
versions, plain-text notes, progress, actionable errors and retry. The explicit
|
its display-only count at `https://shacraft.ru/api/online/minigames`.
|
||||||
install button includes restart. UI session recovery codes, unsaved/failed
|
Aeronautics remains a separate profile and keeps its previous managed files.
|
||||||
settings and account/game operations inhibit that action; native permits are
|
Minigames uses Minecraft 26.2, Fabric Loader 0.19.5, Java 25 and its own
|
||||||
the final authority for concurrent writes. Preferences, sessions and game data
|
`profiles/minigames` game directory. The signed payload supplies Fabric API
|
||||||
live outside the executable and are not migrated or erased by the updater.
|
0.160.0+26.2 and `mods/shacraft-admission-client-0.1.0.jar`; it never supplies
|
||||||
|
Paper, the server plugins, maps, credentials or game-download URLs.
|
||||||
|
|
||||||
`launcher-state/instance.lock` is a process-lifetime OS lock, preventing an idle
|
`fabric.rs` obtains an exact parent/loader profile from fixed Fabric metadata,
|
||||||
second cooperating launcher from retaining old code during replacement. All
|
checks its identity and KnotClient entry point, and converts bounded Maven
|
||||||
native account/settings/game writes hold shared lifecycle permits. Replacement
|
library entries into the existing verified library contract. Each artifact
|
||||||
holds the exclusive permit and `installation-state/writer.lock`, which also
|
URL is constructed from a validated coordinate below fixed Fabric Maven;
|
||||||
checks a game process's durable PID/start-time lease. A normal window close is
|
metadata-supplied alternative origins are rejected. SHA-1 and size come from
|
||||||
inhibited during download/install. Worker permits survive a dropped IPC future.
|
Fabric metadata, or the same Maven's hash sidecar and HEAD for the loader jar.
|
||||||
Before invoking the platform installer, `pending-update.json` records current
|
Java provisioning already accepts exactly Java 25. Automatic installation on
|
||||||
and target versions. The Windows plugin hands off and exits; the marker remains.
|
a platform still requires a real cold-install check on that platform.
|
||||||
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
|
Both profiles claim/display the canonical `aoc` nickname. Only the native
|
||||||
requires its own second check to construct a private installer context; that
|
profile mapping determines ticket `server_id` (`aoc` or `minigames`), and the
|
||||||
check uses the fixed version endpoint, HTTPS host policy and a 30-second timeout,
|
response must match that exact server. The backend is responsible for shared
|
||||||
and its parsed metadata must equal the previously authenticated document.
|
access checks at both issuance and redemption. Existing account sessions and
|
||||||
The plugin's secondary response has no byte-limit API, leaving a memory-use
|
settings need no migration. No copied subscription/whitelist grant is trusted.
|
||||||
risk if that trusted release endpoint serves an unexpectedly large response.
|
|
||||||
Immediately before install, native code re-verifies artifact size/hash/signature;
|
Minigames adds native `--quickPlayMultiplayer 135.106.154.86:25568` at launch.
|
||||||
`Update::install` alone does not perform signature verification.
|
The client-only Fabric companion validates the actual socket target and signs
|
||||||
|
the existing configuration challenge with `minigames` in the transcript.
|
||||||
|
Paper performs verification before entry; its early duplicate UUID gate must
|
||||||
|
run before vanilla would evict the existing player. There is no proxy and no
|
||||||
|
client-side shared secret. One ticket is used for one game connection; a fresh
|
||||||
|
launcher start is required after expiry, consumption or a failed proof attempt.
|
||||||
|
|
||||||
|
The integration is staged until server authentication, signed profile payload,
|
||||||
|
and a newer signed launcher release are deployed and checked together. The
|
||||||
|
existing public 0.1.5 binary cannot select the new profile by a website-only
|
||||||
|
catalog change. Preserve both catalog entries when publishing either profile.
|
||||||
|
|
||||||
|
## Server migration — launcher 0.1.7 (2026-09-17)
|
||||||
|
|
||||||
|
Minigames Quick Play now uses the fixed native endpoint `shacraft.ru:25568`.
|
||||||
|
It no longer pins the retired server IP in the application binary. The Fabric
|
||||||
|
admission client accepts the new actual socket IP `135.106.219.182` and retains
|
||||||
|
`135.106.154.86` for the temporary forwarding path; unrelated hosts and ports
|
||||||
|
remain rejected. The server published updated signed companion manifests.
|
||||||
|
|
||||||
|
The migration's full-platform packages are prepared on
|
||||||
|
`codex/server-migration-20260917`. Existing 0.1.6 installers still connect to the
|
||||||
|
old IP and need that forwarding path until upgraded. Version 0.1.7 uses a newly generated operator-held updater key and the fixed
|
||||||
|
`https://shacraft.ru/launcher/updates/stable-v2.json` channel. It requires one
|
||||||
|
manual installation. Preserve `stable.json` at the last old-key release; never
|
||||||
|
replace it with new-key metadata. Subsequent v2-channel releases use the new
|
||||||
|
key, kept only at `/home/emil/.local/share/shacraft-updater/production.key`.
|
||||||
|
Do not claim publication based on CI packages alone.
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
> From 0.1.7, new installations trust the new operator key and use
|
||||||
|
> `https://shacraft.ru/launcher/updates/stable-v2.json`. Migration from 0.1.6
|
||||||
|
> requires manual installation. Keep the old `stable.json` feed unchanged.
|
||||||
|
> For future releases use `stable-v2.json` in publication commands below.
|
||||||
|
|
||||||
|
# Signed launcher updates
|
||||||
|
|
||||||
|
The application updater is separate from the signed Aeronautics modpack
|
||||||
|
manifest. Its only metadata endpoint is
|
||||||
|
`https://shacraft.ru/launcher/updates/stable.json`. Its artifact URLs are confined
|
||||||
|
to `https://shacraft.ru/downloads/shacraft-launcher/<version>/<filename>`.
|
||||||
|
The webview cannot choose a URL, signing key or executable path. The native
|
||||||
|
updater verifies signatures before installing; an unavailable or invalid feed
|
||||||
|
does not prevent playing with the installed launcher.
|
||||||
|
|
||||||
|
The first version containing the updater must be installed manually. Version
|
||||||
|
0.1.2 has no code capable of installing this feature itself. AppImage supports
|
||||||
|
self-updates from 0.1.3; deb adds them in 0.1.4. An existing 0.1.3 deb therefore
|
||||||
|
needs one manual upgrade to 0.1.4 before its own update button can work. A deb
|
||||||
|
installation keeps its package format and asks for system administrator
|
||||||
|
authorization when installing an update. The launcher itself runs as the normal
|
||||||
|
user. Development binaries use the manual download path. Windows/macOS
|
||||||
|
publication and actual
|
||||||
|
installation tests remain separate release work; supporting a platform in the
|
||||||
|
feed schema does not certify a working release on it.
|
||||||
|
|
||||||
|
## Release-line compatibility
|
||||||
|
|
||||||
|
The archived 2026-09-09 review bundle at
|
||||||
|
`/home/emil/Desktop/shacraft-updater-review/README.md` describes a different,
|
||||||
|
unreleased updater prototype: GitHub-hosted `latest.json`, a different pinned
|
||||||
|
key and the former LoginSystem/Game Bridge proof flow. Its successful CI and
|
||||||
|
prototype version numbers do not establish compatibility with the deployed
|
||||||
|
admission protocol. The 0.1.3 and 0.1.4 releases follow the deployed 0.1.2
|
||||||
|
admission line based on `bf43254`, using the ShaCraft-hosted feed described here.
|
||||||
|
|
||||||
|
Never publish the archived `CI-NOT-FOR-RELEASE`/`CI_NOT_FOR_RELEASE` packages or
|
||||||
|
substitute the unreleased 0.2.0 prototype for an admission-compatible release.
|
||||||
|
Do not merge its updater key, endpoint or account flow blindly: that can break
|
||||||
|
both update continuity and server login. Future reconciliation requires an
|
||||||
|
explicit compatibility review retaining admission support and the key/feed
|
||||||
|
contract already distributed to players, or a separately designed migration.
|
||||||
|
|
||||||
|
## Authentication contract
|
||||||
|
|
||||||
|
The public key embedded in the application is a **dedicated Tauri updater
|
||||||
|
key**, separate from the existing modpack manifest key. Tauri's minisign format
|
||||||
|
wraps the entire minisign public-key/signature text in standard base64. The
|
||||||
|
contents of a `.sig` file belong in metadata, not its filename or URL.
|
||||||
|
|
||||||
|
The stable feed contains the normal Tauri fields `version`, `notes`, `pub_date`
|
||||||
|
and `platforms`, and two additional fields:
|
||||||
|
|
||||||
|
- `signedPayload`: standard base64 of the exact UTF-8 JSON bytes containing
|
||||||
|
only the four normal fields. The publisher produces these bytes with sorted
|
||||||
|
keys, compact separators, literal UTF-8 and no trailing newline.
|
||||||
|
- `metadataSignature`: the Tauri `.sig` contents for those exact payload bytes,
|
||||||
|
signed with the same updater key that signs the application packages.
|
||||||
|
|
||||||
|
The launcher authenticates the payload, requires it to equal the visible
|
||||||
|
fields and then selects the signed platform artifact. This also authenticates
|
||||||
|
the version and artifact URL: an old signed installer cannot be relabelled as
|
||||||
|
a newer release by modifying unsigned metadata. Every artifact is separately
|
||||||
|
verified through Tauri's built-in updater signature check. The current version
|
||||||
|
must increase; there is no unsigned or automatic downgrade fallback.
|
||||||
|
|
||||||
|
The stable publisher accepts only plain `MAJOR.MINOR.PATCH` versions and these
|
||||||
|
platforms: `linux-x86_64` (legacy `.AppImage`), `linux-x86_64-appimage`
|
||||||
|
(`.AppImage`), `linux-x86_64-deb` (`.deb`), `windows-x86_64` (`.exe` or `.msi`),
|
||||||
|
`darwin-x86_64` and `darwin-aarch64` (`.app.tar.gz`). Artifact filenames contain
|
||||||
|
only ASCII letters, digits, dots, underscores and hyphens. Files must already
|
||||||
|
exist in the matching version directory, must not be symlinks and must be
|
||||||
|
between 1 byte and 256 MiB. A platform without a tested signed artifact is
|
||||||
|
omitted, never represented by an empty signature or another platform's file.
|
||||||
|
|
||||||
|
Format-aware Linux feeds must contain both AppImage keys with exactly the same
|
||||||
|
URL and signature. This keeps 0.1.3 clients on their original AppImage path.
|
||||||
|
New AppImage clients prefer `linux-x86_64-appimage` and can read the legacy key;
|
||||||
|
deb clients require `linux-x86_64-deb` and never fall back to an AppImage.
|
||||||
|
Preserve all three entries when publishing a release that supports both formats.
|
||||||
|
|
||||||
|
After verifying each signature, the publisher also checks Linux package format.
|
||||||
|
AppImage must have the ELF64 little-endian x86_64 and type-2 AppImage header.
|
||||||
|
For deb, `/usr/bin/dpkg-deb` must report package `sha-craft-launcher`, architecture
|
||||||
|
`amd64` and the exact signed release version. Inspection uses fixed arguments,
|
||||||
|
no shell, a cleared environment, a 10-second timeout and a 4 KiB output limit.
|
||||||
|
It does not install a package or execute its maintainer scripts. This protects
|
||||||
|
against accidental publication of the wrong signed package; an installer
|
||||||
|
signature remains mandatory and is checked before package inspection.
|
||||||
|
|
||||||
|
## Debian installation boundary
|
||||||
|
|
||||||
|
The installed launcher must be `/usr/bin/shacraft-launcher`, owned by root in
|
||||||
|
root-owned directories that other users cannot write. The package database
|
||||||
|
must assign that file to an installed `sha-craft-launcher` of the expected
|
||||||
|
architecture. The updater needs the system `pkexec` authorization agent; it
|
||||||
|
does not collect a password or fall back to running a shell with privileges.
|
||||||
|
|
||||||
|
After the normal-user downloader verifies the update, `pkexec` launches the
|
||||||
|
fixed installed binary with `--shacraft-install-deb`. This mode runs before
|
||||||
|
Tauri/GTK initialization. It accepts only length-bounded signed metadata and
|
||||||
|
package bytes over stdin, never a user-provided package path. The root helper
|
||||||
|
independently verifies the metadata, selects only the exact deb target, checks
|
||||||
|
the package signature and requires a higher version than the current dpkg
|
||||||
|
database. It writes the verified bytes to a root-created mode-0700 temporary
|
||||||
|
directory under the validated `/var/tmp`; the file has mode 0600.
|
||||||
|
|
||||||
|
The helper checks the package's exact name, version and architecture with
|
||||||
|
`dpkg-deb`, then invokes fixed `dpkg --refuse-downgrade --install` arguments in
|
||||||
|
an environment without inherited variables. Dpkg's own downgrade refusal
|
||||||
|
protects against a competing newer installation between the version check and
|
||||||
|
the package-manager lock. A successful result also requires the package
|
||||||
|
database to report the intended version as installed. The temporary package
|
||||||
|
is removed on completion. A signed deb may include maintainer scripts, which
|
||||||
|
dpkg runs with administrator privileges as part of normal installation: review
|
||||||
|
release package contents before signing.
|
||||||
|
|
||||||
|
Cancellation of system authorization, missing authorization support, signature
|
||||||
|
rejection, a busy package manager and installation failure have distinct
|
||||||
|
messages. There is no automatic retry with weaker checks. Dpkg installation
|
||||||
|
is not an atomic file replacement: dependency/configuration failures or power
|
||||||
|
loss can require normal package-manager recovery. The launcher reports failure
|
||||||
|
instead of claiming the old installation is intact. Successful deb updates
|
||||||
|
restart the fixed installed binary as the ordinary user. These guarantees
|
||||||
|
are separate from AppImage's same-directory atomic replacement.
|
||||||
|
|
||||||
|
## Keys and builds
|
||||||
|
|
||||||
|
The production private key stays **only on the operator's local machine** at
|
||||||
|
`/home/emil/.local/share/shacraft-updater/production.key`, with owner-only
|
||||||
|
permissions. Its public companion is `production.key.pub`. Never transfer the
|
||||||
|
private key to the web server, GitHub, CI, logs, chat, a package or a public
|
||||||
|
artifact. Signing commands below pass the local path, not the key contents.
|
||||||
|
Keep a protected operator-controlled backup: replacing or losing the key will
|
||||||
|
break continuity for installations trusting the existing public key. There is
|
||||||
|
no automatic key rotation mechanism in this release.
|
||||||
|
|
||||||
|
Normal `build.yml` jobs explicitly merge `scripts/tauri-unsigned.json` to disable
|
||||||
|
updater signing. They upload ordinary packages and unsigned macOS `.app.tar.gz`
|
||||||
|
archives. CI does not receive the production key and does not publish the
|
||||||
|
stable feed. A release operator reviews/tests these build artifacts, then signs
|
||||||
|
the chosen packages locally. For a signed local Tauri bundle build, set
|
||||||
|
`TAURI_SIGNING_PRIVATE_KEY` to the protected key path; never disable verification
|
||||||
|
in the application to make a build pass.
|
||||||
|
|
||||||
|
Updater signatures authenticate ShaCraft's update channel. They are separate
|
||||||
|
from Windows Authenticode, Apple signing/notarization, and Linux distribution
|
||||||
|
package signatures; passing updater checks does not establish those assurances.
|
||||||
|
|
||||||
|
## Local preparation and signing
|
||||||
|
|
||||||
|
The publisher requires Python 3.10+ and `minisign`; releases containing deb also
|
||||||
|
require `/usr/bin/dpkg-deb` (Debian/Ubuntu's `dpkg` package). It performs verification
|
||||||
|
through the standard minisign CLI, without implementing cryptography in Python.
|
||||||
|
`--minisign /absolute/path/to/minisign` supports a locally extracted tool without
|
||||||
|
installing a global package. Run these examples from the launcher repository,
|
||||||
|
substituting the actual release version and tested filenames.
|
||||||
|
|
||||||
|
1. Stage immutable, tested packages below a local downloads root. The following
|
||||||
|
example assumes both tested Linux artifacts already exist below
|
||||||
|
`/tmp/shacraft-release/downloads/0.1.4/`
|
||||||
|
and release notes exist at `/tmp/shacraft-release/notes.txt`. Create signatures
|
||||||
|
with the Tauri CLI; `.sig` is written beside each artifact:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run tauri -- signer sign \
|
||||||
|
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
|
||||||
|
/tmp/shacraft-release/downloads/0.1.4/ShaCraft.Launcher_0.1.4_amd64.AppImage
|
||||||
|
npm run tauri -- signer sign \
|
||||||
|
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
|
||||||
|
/tmp/shacraft-release/downloads/0.1.4/ShaCraft.Launcher_0.1.4_amd64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Prepare a deterministic payload after verifying every package signature.
|
||||||
|
Repeat `--artifact PLATFORM=FILENAME` for each tested platform included in this
|
||||||
|
release. Keep the legacy AppImage alias. Do not list a dmg, nonexistent
|
||||||
|
package or untested architecture:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/publish_launcher_update.py prepare \
|
||||||
|
--version 0.1.4 \
|
||||||
|
--downloads-root /tmp/shacraft-release/downloads \
|
||||||
|
--artifact linux-x86_64=ShaCraft.Launcher_0.1.4_amd64.AppImage \
|
||||||
|
--artifact linux-x86_64-appimage=ShaCraft.Launcher_0.1.4_amd64.AppImage \
|
||||||
|
--artifact linux-x86_64-deb=ShaCraft.Launcher_0.1.4_amd64.deb \
|
||||||
|
--notes-file /tmp/shacraft-release/notes.txt \
|
||||||
|
--public-key /home/emil/.local/share/shacraft-updater/production.key.pub \
|
||||||
|
--payload /tmp/shacraft-release/release.payload.json
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Inspect the payload and sign its exact bytes locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run tauri -- signer sign \
|
||||||
|
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
|
||||||
|
/tmp/shacraft-release/release.payload.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Editing notes, timestamps, versions, signatures or URLs after this step
|
||||||
|
invalidates the metadata signature. Prepare and sign again after any change.
|
||||||
|
|
||||||
|
## Publication
|
||||||
|
|
||||||
|
Upload **only** the packages, their `.sig` files, `release.payload.json`, its
|
||||||
|
`.sig`, the public key and the publisher script. Stage and hash-check artifacts
|
||||||
|
before publishing metadata. Production paths are:
|
||||||
|
|
||||||
|
- Downloads root: `/root/shacraft/caddy/www/downloads/shacraft-launcher`.
|
||||||
|
- Stable feed: `/root/shacraft/data/launcher/updates/stable.json`.
|
||||||
|
- Public feed: `https://shacraft.ru/launcher/updates/stable.json`.
|
||||||
|
|
||||||
|
Keep previous version directories immutable and save the current feed before
|
||||||
|
replacing it. Run the publisher on the host with a public-key file and minisign
|
||||||
|
available there. Neither operation needs a private key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 publish_launcher_update.py publish \
|
||||||
|
--downloads-root /root/shacraft/caddy/www/downloads/shacraft-launcher \
|
||||||
|
--public-key /path/to/production.key.pub \
|
||||||
|
--payload /path/to/release.payload.json \
|
||||||
|
--signature /path/to/release.payload.json.sig \
|
||||||
|
--output /root/shacraft/data/launcher/updates/stable.json \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
After that succeeds, repeat without `--dry-run`. The publisher verifies metadata
|
||||||
|
and all artifacts under the public key, authenticates the previous feed before
|
||||||
|
comparing versions, and refuses same-version replacement or downgrade. It holds
|
||||||
|
an exclusive publication lock and writes/fsyncs a temporary sibling before
|
||||||
|
atomically replacing `stable.json`. Dry-run validates everything but does not
|
||||||
|
replace the feed. Do not change staged artifacts concurrently with publication.
|
||||||
|
Do not overwrite a released version to add another platform: publish a higher
|
||||||
|
version containing the complete intended platform set.
|
||||||
|
|
||||||
|
Alternatively, run the same verification locally against byte-for-byte copies
|
||||||
|
of the current feed and staged downloads, then deploy the resulting feed only
|
||||||
|
after checking uploaded package and metadata hashes against those validated
|
||||||
|
files. An initial publication has no previous feed; subsequent publications
|
||||||
|
must validate against the actual deployed feed, not an empty staging directory.
|
||||||
|
|
||||||
|
Caddy should serve this feed as JSON with `Cache-Control: no-store`. Check the
|
||||||
|
public response, decoded metadata, signatures and downloadable artifact hashes
|
||||||
|
after deployment. Exercise a real installed AppImage updating to a higher
|
||||||
|
version, including relaunch and retained settings/account state. For deb, also
|
||||||
|
exercise administrator cancellation, package-manager lock conflicts, failed
|
||||||
|
installation and a successful package upgrade/relaunch. Use an isolated system
|
||||||
|
for destructive package-manager failure cases; never modify player data as a
|
||||||
|
test fixture. Unit tests, packaging or a browser mock alone do not establish
|
||||||
|
successful installation or distribution compatibility.
|
||||||
|
If a release is faulty, stop offering it and publish a corrected higher version;
|
||||||
|
do not weaken signature checks or silently downgrade users.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover -s scripts -p 'test_*.py'
|
||||||
|
```
|
||||||
|
|
||||||
|
Publisher tests exercise the real minisign CLI with temporary test keys,
|
||||||
|
including valid publication, modified packages and metadata, authenticated
|
||||||
|
previous-version checks, downgrade refusal, URL/path restrictions and dry-run.
|
||||||
|
Linux tests also build real temporary deb packages with `dpkg-deb`, validate
|
||||||
|
package/version/architecture, ensure inspection never executes maintainer
|
||||||
|
scripts, retain the legacy AppImage feed alias, and reject malformed signed
|
||||||
|
Linux packages. Package-inspection output and time bounds are exercised.
|
||||||
|
No test private key is checked into the repository. CI installs minisign so
|
||||||
|
the signature tests run; locally they explicitly skip if the tool is absent.
|
||||||
|
Set `SHACRAFT_TEST_MINISIGN` to use an extracted executable.
|
||||||
|
|
||||||
|
The artifact formats and signature encoding follow the
|
||||||
|
[official Tauri updater documentation](https://v2.tauri.app/plugin/updater/).
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
## Cross-platform release 0.1.5 (2026-09-10)
|
||||||
|
|
||||||
|
Published Windows x64 EXE/MSI, macOS aarch64 and x86_64 DMG/app.tar.gz,
|
||||||
|
and Linux x64 AppImage/DEB on https://shacraft.ru/help#launcher. The signed
|
||||||
|
stable feed includes all platforms plus the legacy/exact Linux aliases.
|
||||||
|
CI source b43fc610c43a9ec9f5f3ffce601de9604670ff8a, successful Actions run
|
||||||
|
https://github.com/emil28092005/shacraft-launcher/actions/runs/34512683651.
|
||||||
|
An initial non-Linux borrow/move compilation error in updater target selection
|
||||||
|
was fixed before the final build. Native tests: Windows70, macOS74 per arch,
|
||||||
|
Linux84; UI tests pass on all four runners. Local checks verify macOS bundle
|
||||||
|
version/CPU type, Linux package identity and every artifact signature. Public
|
||||||
|
HTTPS downloads of all eight artifacts match local SHA-256. Website418 tests
|
||||||
|
plus48 subtests pass; only backend recreated, game containers unchanged.
|
||||||
|
Updater signatures use the existing operator-held key, never uploaded to CI or
|
||||||
|
server. Windows installers have no Authenticode signature; macOS is not Apple
|
||||||
|
notarized. Native CI tests and packaging do not certify full Minecraft installs
|
||||||
|
or desktop updater/restart behavior on Windows/macOS. Older unsupported clients
|
||||||
|
need a manual installation of the current release. Previous releases immutable.
|
||||||
|
|
||||||
|
The live native updater test passed against the published 0.1.5 feed: verified download, corrupted-byte rejection and replacement of only a temporary AppImage copy. The user-installed launcher was not modified.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Launcher 0.1.6 release
|
||||||
|
|
||||||
|
Adds a separate Minigames profile (Minecraft 26.2 / Fabric 0.19.5 / Java 25)
|
||||||
|
while keeping Aeronautics and its installed profile intact. Both use the
|
||||||
|
existing canonical aoc nickname and shared access. Tickets remain bound to
|
||||||
|
the selected server. Minigames connects to 135.106.154.86:25568 and proves
|
||||||
|
account admission during configuration before world entry.
|
||||||
|
|
||||||
|
Local checks: 87 native tests, 33 UI tests, TypeScript/Vite, three Java client
|
||||||
|
proof tests, an official Fabric metadata resolution check and the Linux native
|
||||||
|
release build. The [real Fabric/Paper smoke](verification/minigames-fabric-2026-09-13.json)
|
||||||
|
passed with a synthetic account: the unchanged production companion entered
|
||||||
|
the lobby and the backend consumed its Minigames ticket. Published on 2026-09-13:
|
||||||
|
all four cross-platform jobs succeeded in [build run 34778218510](https://github.com/emil28092005/shacraft-launcher/actions/runs/34778218510)
|
||||||
|
at runtime source `799fa692ef5e775f0044fe300f65fd4564771d47`.
|
||||||
|
[Checks run 34778334417](https://github.com/emil28092005/shacraft-launcher/actions/runs/34778334417)
|
||||||
|
passed at `5b741771b4668a834a2c5b757379340f11b8ae3a`; the only difference is the
|
||||||
|
CI job that runs all 18 publisher signature tests on Ubuntu 24.04. The CI Fabric
|
||||||
|
jar exactly matches the one used by the real client smoke.
|
||||||
|
|
||||||
|
All eight packages were signed locally, verified again on the server, and
|
||||||
|
published after a successful dry-run. Full public HTTPS downloads match the
|
||||||
|
recorded SHA-256 values; package signatures and the public feed signature pass.
|
||||||
|
The feed payload equals the locally signed canonical bytes. See the
|
||||||
|
[publication receipt](verification/launcher-release-0.1.6.json). The previous
|
||||||
|
0.1.5 feed was backed up; its packages remain unchanged. The private key stayed
|
||||||
|
on the operator workstation.
|
||||||
|
|
||||||
|
## Build contract
|
||||||
|
|
||||||
|
Git remote: `git@github.com:emil28092005/shacraft-launcher.git`.
|
||||||
|
The production admission line is branch `codex/launcher-updater`; `main` is a
|
||||||
|
divergent unreleased prototype and must remain unchanged for this release.
|
||||||
|
Push the reviewed commit to the production branch, then dispatch
|
||||||
|
`gh workflow run build.yml --ref codex/launcher-updater --repo emil28092005/shacraft-launcher`.
|
||||||
|
The check workflow runs on branch pushes. Verify each run's head SHA equals the
|
||||||
|
reviewed commit before downloading artifacts. A main push also triggers the
|
||||||
|
build matrix, but was not the publication path for this release.
|
||||||
|
CI publishes unsigned artifacts named:
|
||||||
|
|
||||||
|
- `shacraft-launcher-linux-x64`: AppImage and deb.
|
||||||
|
- `shacraft-launcher-windows-x64`: NSIS exe and MSI.
|
||||||
|
- `shacraft-launcher-macos-arm64`: aarch64 app.tar.gz and DMG.
|
||||||
|
- `shacraft-launcher-macos-x64`: x86_64 app.tar.gz and DMG.
|
||||||
|
|
||||||
|
`.github/workflows/check.yml` additionally tests the Java 25 admission client
|
||||||
|
and uploads `shacraft-admission-client`. It has no production credentials.
|
||||||
|
Download the artifacts from the checked run at the exact reviewed commit with
|
||||||
|
`gh run download RUN_ID --repo emil28092005/shacraft-launcher --dir STAGING`.
|
||||||
|
|
||||||
|
## Signing and publication
|
||||||
|
|
||||||
|
Stage renamed ASCII filenames below a local downloads root, for example
|
||||||
|
`/tmp/shacraft-release-0.1.6/downloads/0.1.6/`. Preserve already published
|
||||||
|
0.1.5 bytes. Expected updater filenames:
|
||||||
|
|
||||||
|
- `ShaCraft.Launcher_0.1.6_amd64.AppImage`
|
||||||
|
- `ShaCraft.Launcher_0.1.6_amd64.deb`
|
||||||
|
- `ShaCraft.Launcher_0.1.6_x64-setup.exe`
|
||||||
|
- `ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz`
|
||||||
|
- `ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz`
|
||||||
|
|
||||||
|
MSI and DMG are additional manual downloads; the updater uses EXE and app.tar.gz.
|
||||||
|
Inspect package versions, architecture and contents before signing. Sign each
|
||||||
|
chosen file with the existing local operator key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run tauri -- signer sign --private-key-path /home/emil/.local/share/shacraft-updater/production.key ARTIFACT
|
||||||
|
```
|
||||||
|
|
||||||
|
The key file is mode 0600 and remains local. Never read its contents into logs,
|
||||||
|
copy it to CI/server or substitute a different signing identity. Existing
|
||||||
|
`production.key.pub` is sufficient for every later verification/publication.
|
||||||
|
|
||||||
|
After creating a UTF-8 release notes file, prepare the payload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/publish_launcher_update.py prepare \
|
||||||
|
--version 0.1.6 \
|
||||||
|
--downloads-root /tmp/shacraft-release-0.1.6/downloads \
|
||||||
|
--artifact linux-x86_64=ShaCraft.Launcher_0.1.6_amd64.AppImage \
|
||||||
|
--artifact linux-x86_64-appimage=ShaCraft.Launcher_0.1.6_amd64.AppImage \
|
||||||
|
--artifact linux-x86_64-deb=ShaCraft.Launcher_0.1.6_amd64.deb \
|
||||||
|
--artifact windows-x86_64=ShaCraft.Launcher_0.1.6_x64-setup.exe \
|
||||||
|
--artifact darwin-aarch64=ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz \
|
||||||
|
--artifact darwin-x86_64=ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz \
|
||||||
|
--notes-file /tmp/shacraft-release-0.1.6/notes.txt \
|
||||||
|
--public-key /home/emil/.local/share/shacraft-updater/production.key.pub \
|
||||||
|
--payload /tmp/shacraft-release-0.1.6/release.payload.json
|
||||||
|
npm run tauri -- signer sign \
|
||||||
|
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
|
||||||
|
/tmp/shacraft-release-0.1.6/release.payload.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload only public packages, signatures, payload, public key and publisher.
|
||||||
|
Server downloads root is `/root/shacraft/caddy/www/downloads/shacraft-launcher`;
|
||||||
|
public artifact URLs are `https://shacraft.ru/downloads/shacraft-launcher/0.1.6/`
|
||||||
|
followed by the checked filename. Preserve the old feed before publication.
|
||||||
|
With the exact staged server paths, run the existing publisher first with
|
||||||
|
`--dry-run`, then without it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 publish_launcher_update.py publish \
|
||||||
|
--downloads-root /root/shacraft/caddy/www/downloads/shacraft-launcher \
|
||||||
|
--public-key PUBLIC_KEY_FILE \
|
||||||
|
--payload SIGNED_PAYLOAD_FILE \
|
||||||
|
--signature PAYLOAD_SIGNATURE_FILE \
|
||||||
|
--output /root/shacraft/data/launcher/updates/stable.json \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Publication depends on the signed Minigames profile containing the final client
|
||||||
|
and Fabric API jars, the healthy Paper admission gate, and the shared-access
|
||||||
|
backend endpoints being available. Verify public HTTPS package hashes, feed
|
||||||
|
signatures and an actual client admission before updating the website buttons.
|
||||||
|
OS Authenticode/Apple notarization and cold installations on other platforms
|
||||||
|
remain distinct from successful native CI/builds.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Launcher 0.1.7 release — server migration
|
||||||
|
|
||||||
|
Published on 2026-09-17. Minigames Quick Play uses `shacraft.ru:25568`;
|
||||||
|
the Fabric admission companion accepts the new server's actual socket IP
|
||||||
|
`135.106.219.182`, the domain and the temporary old-IP forwarding path.
|
||||||
|
|
||||||
|
The owner approved replacing the unavailable updater signing key and requiring
|
||||||
|
one manual installation. Users of 0.1.6 and earlier must close Minecraft and
|
||||||
|
the launcher, then install 0.1.7 from [the download page](https://shacraft.ru/help#launcher).
|
||||||
|
Their installed game profiles do not need to be reinstalled. The new native
|
||||||
|
updater endpoint is `https://shacraft.ru/launcher/updates/stable-v2.json`.
|
||||||
|
The old `stable.json` remains byte-identical at 0.1.6 under its original key.
|
||||||
|
Never publish new-key signatures to that old channel.
|
||||||
|
|
||||||
|
## Provenance and verification
|
||||||
|
|
||||||
|
Runtime source: `b674347e867af3edb6f00201bd30f1db7af4d9d2` on
|
||||||
|
`codex/server-migration-20260917`.
|
||||||
|
All four jobs succeeded in [build run 35167814993](https://github.com/emil28092005/shacraft-launcher/actions/runs/35167814993).
|
||||||
|
[Checks run 35167815151](https://github.com/emil28092005/shacraft-launcher/actions/runs/35167815151)
|
||||||
|
passed native checks, publisher signature tests and the Java 25 Fabric build.
|
||||||
|
Downloaded artifact ZIP hashes match GitHub's SHA-256 digests and their run
|
||||||
|
metadata points to the exact runtime commit. Local checks passed 33 UI tests,
|
||||||
|
87 Rust tests (8 live/desktop tests ignored), TypeScript and Vite.
|
||||||
|
|
||||||
|
All eight packages were signed on the operator workstation, checked again on
|
||||||
|
the new server, and published after the metadata publisher's dry-run. Complete
|
||||||
|
public HTTPS downloads match the recorded hashes; all eight package signatures
|
||||||
|
and the v2 feed's metadata signature verify. The public feed bytes exactly match
|
||||||
|
the locally verified metadata and carry `Cache-Control: no-store`. The website
|
||||||
|
shows 0.1.7 links and manual installation instructions. See the
|
||||||
|
[publication receipt](verification/launcher-release-0.1.7.json).
|
||||||
|
|
||||||
|
Native executables extracted from Windows NSIS, Linux DEB and both macOS app
|
||||||
|
archives contain the domain endpoint, v2 channel and expected public key, with
|
||||||
|
no old IP literal. The CI-built Fabric companion was published at immutable
|
||||||
|
SHA-256 `6ef059192cef0242839db3d6a234228b771196f23b9b1e307a17a27669632a90`.
|
||||||
|
Its public profile signature uses the original profile key and verifies;
|
||||||
|
Java 25 target checks accept only the intended destinations. Minecraft status
|
||||||
|
queries work through both new and old public IPs (Paper 26.2, protocol 776).
|
||||||
|
This release verification does not establish a fresh Windows/macOS installation
|
||||||
|
or an authenticated game session on the migrated host. Authenticode and Apple
|
||||||
|
notarization remain separate from the updater signature.
|
||||||
|
|
||||||
|
## Operator state
|
||||||
|
|
||||||
|
The new updater private key is only at
|
||||||
|
`/home/emil/.local/share/shacraft-updater/production.key` (0600; parent 0700).
|
||||||
|
Its public companion's SHA-256, after trimming whitespace, is
|
||||||
|
`5ac34dd380307ab25fcfc1479ee6d653b9b43bce45b5a242950d09c01fe1b2f9`.
|
||||||
|
Maintain an encrypted owner-controlled backup. No private key was uploaded to
|
||||||
|
GitHub or the server. Future versions must use this same key and the v2 feed.
|
||||||
|
|
||||||
|
Production is `135.106.219.182`. Release evidence is under
|
||||||
|
`/root/shacraft/.release-staging/launcher-0.1.7`, with the exact runtime source
|
||||||
|
snapshot at `/root/shacraft-launcher-0.1.7`. The unversioned launcher checkout on
|
||||||
|
that host is older. The website image is `shacraft/backend:migration-017-20260917`.
|
||||||
|
|
||||||
|
The old host `135.106.154.86` only forwards ports 80, 443 and 25568. Keep it
|
||||||
|
until authoritative DNS and caches have updated **and** users of 0.1.6 have
|
||||||
|
manually upgraded: those binaries pin the old IP independently of DNS.
|
||||||
|
Do not restart old application containers or Hermes. Detailed migration and
|
||||||
|
rollback context is in `/context/migration-20260917.md` on the new host.
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"recorded_at": "2026-09-13T20:03:46.913196+00:00",
|
||||||
|
"version": "0.1.6",
|
||||||
|
"source_sha": "799fa692ef5e775f0044fe300f65fd4564771d47",
|
||||||
|
"branch": "codex/launcher-updater",
|
||||||
|
"build_run": 34778218510,
|
||||||
|
"checks_run": 34778334417,
|
||||||
|
"stable_feed": "https://shacraft.ru/launcher/updates/stable.json",
|
||||||
|
"feed_sha256": "342876aee8760a984421889cec1453dd642ebf988ab2bad27f23ea4f4f203f8d",
|
||||||
|
"feed_signature_verified": true,
|
||||||
|
"feed_payload_equals_locally_signed_payload": true,
|
||||||
|
"platforms": [
|
||||||
|
"darwin-aarch64",
|
||||||
|
"darwin-x86_64",
|
||||||
|
"linux-x86_64",
|
||||||
|
"linux-x86_64-appimage",
|
||||||
|
"linux-x86_64-deb",
|
||||||
|
"windows-x86_64"
|
||||||
|
],
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"artifact": "ShaCraft.Launcher_0.1.6_amd64.AppImage",
|
||||||
|
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_amd64.AppImage",
|
||||||
|
"size": 83274232,
|
||||||
|
"sha256": "fa01905baddebd08b59ab7855009f0b29df02fcd3813941a0190f5135ff58f7c",
|
||||||
|
"public_signature_verified": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifact": "ShaCraft.Launcher_0.1.6_amd64.deb",
|
||||||
|
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_amd64.deb",
|
||||||
|
"size": 5997818,
|
||||||
|
"sha256": "01621fde16f6fc1ca453acffb1d22d664c8d8b60909d22a8aa2af10ac80d8737",
|
||||||
|
"public_signature_verified": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifact": "ShaCraft.Launcher_0.1.6_x64-setup.exe",
|
||||||
|
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x64-setup.exe",
|
||||||
|
"size": 3793645,
|
||||||
|
"sha256": "0bb2a577d88d4387f23efcd28b19bf83e373b1b10c5cbbb5d6e0f69428d9f9ab",
|
||||||
|
"public_signature_verified": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifact": "ShaCraft.Launcher_0.1.6_x64_en-US.msi",
|
||||||
|
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x64_en-US.msi",
|
||||||
|
"size": 5554176,
|
||||||
|
"sha256": "8a53a9d8d6dbdd98238b294df604fe5050375e64709dfdff942bf60fcd61519b",
|
||||||
|
"public_signature_verified": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifact": "ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz",
|
||||||
|
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz",
|
||||||
|
"size": 5876796,
|
||||||
|
"sha256": "c113afc0794eaa6788cb994e7d84f821d22573d559f3cddfb0bcd5c2cad557cf",
|
||||||
|
"public_signature_verified": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifact": "ShaCraft.Launcher_0.1.6_aarch64.dmg",
|
||||||
|
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_aarch64.dmg",
|
||||||
|
"size": 6420101,
|
||||||
|
"sha256": "0391a1d4afa6cb21663f1f8de367ecdd68a51656db48e86ad0bec617270c6036",
|
||||||
|
"public_signature_verified": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifact": "ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz",
|
||||||
|
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz",
|
||||||
|
"size": 6032564,
|
||||||
|
"sha256": "ee025cd13af1887334d0af5a7e04259eddd6339fe8509b108e0b3ac0e7268daf",
|
||||||
|
"public_signature_verified": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"artifact": "ShaCraft.Launcher_0.1.6_x64.dmg",
|
||||||
|
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.6/ShaCraft.Launcher_0.1.6_x64.dmg",
|
||||||
|
"size": 6591340,
|
||||||
|
"sha256": "cd54c8f975bb7a1ec587bbadde35caf4a3eb9a1f86182150310590e41dec4023",
|
||||||
|
"public_signature_verified": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"previous_feed_version": "0.1.5",
|
||||||
|
"previous_feed_sha256": "d81ab2b007f7f9a385f2ec28d17f204e75c0c2e92cf40931064dd66d3bd2898f",
|
||||||
|
"previous_feed_backup": "/root/shacraft/.release-staging/launcher-0.1.6/stable.before-0.1.6.json",
|
||||||
|
"private_key_location": "remains on operator workstation; not uploaded",
|
||||||
|
"remote_publication": "all8 local signatures verified, remote hashes/signatures verified, dry-run passed, atomic publish passed",
|
||||||
|
"limitations": "Cross-platform CI and package inspection passed. Windows/macOS cold installation and OS signing/notarization were not exercised."
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"source_sha": "b674347e867af3edb6f00201bd30f1db7af4d9d2",
|
||||||
|
"version": "0.1.7",
|
||||||
|
"build_run": 35167814993,
|
||||||
|
"new_channel": "https://shacraft.ru/launcher/updates/stable-v2.json",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "ShaCraft.Launcher_0.1.7_aarch64.app.tar.gz",
|
||||||
|
"bytes": 5877025,
|
||||||
|
"sha256": "c460fd5c766b1e1f64d55e23952bead6c1e6a1ecd395e8147ce625aeca4049f7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ShaCraft.Launcher_0.1.7_aarch64.dmg",
|
||||||
|
"bytes": 6420405,
|
||||||
|
"sha256": "be869b3221363ddb9fcc755bf76a78d3e44264c0bc3a24bcd9d4396ee118a7cb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ShaCraft.Launcher_0.1.7_amd64.AppImage",
|
||||||
|
"bytes": 83270136,
|
||||||
|
"sha256": "a2e2337e1b0ee07f062383fa2e3da8e8fc51c62e2ccacda89a45a1ea0d092351"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ShaCraft.Launcher_0.1.7_amd64.deb",
|
||||||
|
"bytes": 5997780,
|
||||||
|
"sha256": "b6b9913063600659dfd917c7939e3c07675ca81e35c34cf6a90edc61ef30328e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ShaCraft.Launcher_0.1.7_x64-setup.exe",
|
||||||
|
"bytes": 3792580,
|
||||||
|
"sha256": "0b1167dae9fce7d85f1ad95b2857cce1df7d17fffcdd6c358901ff998c5ac6b6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ShaCraft.Launcher_0.1.7_x64.dmg",
|
||||||
|
"bytes": 6591601,
|
||||||
|
"sha256": "9b6cc5660817f4dd1684282e01daa1639d98e987325ec4924b42b07ae07e44d5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ShaCraft.Launcher_0.1.7_x64_en-US.msi",
|
||||||
|
"bytes": 5554176,
|
||||||
|
"sha256": "54d0e8425f0087f13c396efa464da5e8e95203e222353d66c11d68f1076abbb4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ShaCraft.Launcher_0.1.7_x86_64.app.tar.gz",
|
||||||
|
"bytes": 6032631,
|
||||||
|
"sha256": "05748cc29462f850a12da3cd84336872af4ee8f5a5de0a3b10d91461c60fbad7"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"verified_at": "2026-09-17T01:09:31.123799+00:00",
|
||||||
|
"public_https_packages": [
|
||||||
|
{
|
||||||
|
"name": "ShaCraft.Launcher_0.1.7_aarch64.app.tar.gz",
|
||||||
|
"bytes": 5877025,
|
||||||
|
"sha256": "c460fd5c766b1e1f64d55e23952bead6c1e6a1ecd395e8147ce625aeca4049f7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ShaCraft.Launcher_0.1.7_aarch64.dmg",
|
||||||
|
"bytes": 6420405,
|
||||||
|
"sha256": "be869b3221363ddb9fcc755bf76a78d3e44264c0bc3a24bcd9d4396ee118a7cb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ShaCraft.Launcher_0.1.7_amd64.AppImage",
|
||||||
|
"bytes": 83270136,
|
||||||
|
"sha256": "a2e2337e1b0ee07f062383fa2e3da8e8fc51c62e2ccacda89a45a1ea0d092351"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ShaCraft.Launcher_0.1.7_amd64.deb",
|
||||||
|
"bytes": 5997780,
|
||||||
|
"sha256": "b6b9913063600659dfd917c7939e3c07675ca81e35c34cf6a90edc61ef30328e"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ShaCraft.Launcher_0.1.7_x64-setup.exe",
|
||||||
|
"bytes": 3792580,
|
||||||
|
"sha256": "0b1167dae9fce7d85f1ad95b2857cce1df7d17fffcdd6c358901ff998c5ac6b6"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ShaCraft.Launcher_0.1.7_x64.dmg",
|
||||||
|
"bytes": 6591601,
|
||||||
|
"sha256": "9b6cc5660817f4dd1684282e01daa1639d98e987325ec4924b42b07ae07e44d5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ShaCraft.Launcher_0.1.7_x64_en-US.msi",
|
||||||
|
"bytes": 5554176,
|
||||||
|
"sha256": "54d0e8425f0087f13c396efa464da5e8e95203e222353d66c11d68f1076abbb4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ShaCraft.Launcher_0.1.7_x86_64.app.tar.gz",
|
||||||
|
"bytes": 6032631,
|
||||||
|
"sha256": "05748cc29462f850a12da3cd84336872af4ee8f5a5de0a3b10d91461c60fbad7"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"old_feed_preserved": true,
|
||||||
|
"feed_sha256": "b46d9a6ef974e177fd3b0d945ff22efb0ec0afaaa71bae7f7a23003997682166"
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"recorded_at": "2026-09-13T19:32:59.436884+00:00",
|
||||||
|
"result": "joined_world",
|
||||||
|
"client": "Minecraft 26.2 / Fabric Loader 0.19.5 / Fabric API 0.160.0+26.2",
|
||||||
|
"java": "25.0.2",
|
||||||
|
"client_jar_sha256": "3335626b7c8fdd233e8531ad382594398c7df48842b1b919934d1154dd4ba8f1",
|
||||||
|
"client_jar_size": 10667,
|
||||||
|
"profile": "minigames",
|
||||||
|
"synthetic_player": "AdmissionPilot",
|
||||||
|
"endpoint": "127.0.0.1:25608",
|
||||||
|
"environment": "isolated Xvfb :95; separate temporary game directory; synthetic account/ticket; explicit local socket test flag",
|
||||||
|
"server_evidence": [
|
||||||
|
"[22:32:02 INFO]: AdmissionPilot joined the game",
|
||||||
|
"[22:32:02 INFO]: AdmissionPilot[/127.0.0.1:54602] logged in with entity id 77 at ([minecraft:shacraft_lobby_v2]0.5, 96.0, 43.5)"
|
||||||
|
],
|
||||||
|
"verified": [
|
||||||
|
"production companion jar bytes unchanged",
|
||||||
|
"queued minecraft:register during configuration",
|
||||||
|
"server-bound Ed25519 response accepted",
|
||||||
|
"actual vanilla client entered lobby world"
|
||||||
|
],
|
||||||
|
"not_tested": [
|
||||||
|
"production public endpoint login",
|
||||||
|
"Windows/macOS cold installation",
|
||||||
|
"full GUI launcher install/account flow"
|
||||||
|
],
|
||||||
|
"notes": "Offline client emits Microsoft profile-certificate HTTP401; admission and world entry succeeded. Real account/session/password not used.",
|
||||||
|
"backend_evidence": {
|
||||||
|
"source": "backend agent read-only synthetic SQLite query",
|
||||||
|
"issued_minigames_tickets_for_player": 1,
|
||||||
|
"consumed_minigames_tickets_for_player": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "shacraft-launcher-ui",
|
"name": "shacraft-launcher-ui",
|
||||||
"version": "0.2.0",
|
"version": "0.1.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "shacraft-launcher-ui",
|
"name": "shacraft-launcher-ui",
|
||||||
"version": "0.2.0",
|
"version": "0.1.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "2.11.1",
|
"@tauri-apps/api": "2.11.1",
|
||||||
"lucide-react": "1.41.0",
|
"lucide-react": "1.41.0",
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "shacraft-launcher-ui",
|
"name": "shacraft-launcher-ui",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.0",
|
"version": "0.1.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
__pycache__/
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prepare and atomically publish a signed ShaCraft stable updater feed.
|
||||||
|
|
||||||
|
Requires Python 3.10+ and the minisign CLI. Only public keys are inputs.
|
||||||
|
Signing is deliberately a separate, operator-controlled action.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import contextlib
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import selectors
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
ORIGIN = "https://shacraft.ru/downloads/shacraft-launcher/"
|
||||||
|
PLATFORMS = {
|
||||||
|
"linux-x86_64": (".AppImage",),
|
||||||
|
"linux-x86_64-appimage": (".AppImage",),
|
||||||
|
"linux-x86_64-deb": (".deb",),
|
||||||
|
"windows-x86_64": (".exe", ".msi"),
|
||||||
|
"darwin-x86_64": (".app.tar.gz",),
|
||||||
|
"darwin-aarch64": (".app.tar.gz",),
|
||||||
|
}
|
||||||
|
FIELDS = {"version", "notes", "pub_date", "platforms"}
|
||||||
|
MAX_ARTIFACT_BYTES = 256 * 1024 * 1024
|
||||||
|
MAX_METADATA_BYTES = 64 * 1024
|
||||||
|
DEB_PACKAGE = "sha-craft-launcher"
|
||||||
|
DPKG_DEB = "/usr/bin/dpkg-deb"
|
||||||
|
PACKAGE_TOOL_ENV = {"PATH": "/usr/bin:/bin", "LC_ALL": "C"}
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidRelease(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def version_tuple(version):
|
||||||
|
if not isinstance(version, str) or not re.fullmatch(
|
||||||
|
r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version
|
||||||
|
):
|
||||||
|
raise InvalidRelease("stable version must be plain MAJOR.MINOR.PATCH")
|
||||||
|
parts = tuple(map(int, version.split(".")))
|
||||||
|
if any(part > 2**64 - 1 for part in parts):
|
||||||
|
raise InvalidRelease("version component exceeds SemVer range")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def artifact_name(platform, filename):
|
||||||
|
if platform not in PLATFORMS:
|
||||||
|
raise InvalidRelease("unsupported updater platform")
|
||||||
|
if not isinstance(filename, str) or not re.fullmatch(
|
||||||
|
r"[A-Za-z0-9][A-Za-z0-9._-]{0,199}", filename
|
||||||
|
):
|
||||||
|
raise InvalidRelease("artifact filename must be a plain ASCII filename")
|
||||||
|
if not filename.endswith(PLATFORMS[platform]):
|
||||||
|
raise InvalidRelease("artifact suffix does not match updater platform")
|
||||||
|
return filename
|
||||||
|
|
||||||
|
|
||||||
|
def regular_file(path, limit):
|
||||||
|
info = path.lstat()
|
||||||
|
if not stat.S_ISREG(info.st_mode) or info.st_size == 0 or info.st_size > limit:
|
||||||
|
raise InvalidRelease("input must be a nonempty regular file within size limit")
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def read_file(path, limit=MAX_METADATA_BYTES):
|
||||||
|
regular_file(path, limit)
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
value = stream.read(limit + 1)
|
||||||
|
if len(value) > limit:
|
||||||
|
raise InvalidRelease("input exceeds size limit")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def decode_tauri(value):
|
||||||
|
if not isinstance(value, str) or not value or len(value) > MAX_METADATA_BYTES:
|
||||||
|
raise InvalidRelease("invalid Tauri base64 value")
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(value, validate=True)
|
||||||
|
decoded.decode("utf-8")
|
||||||
|
except (binascii.Error, UnicodeDecodeError) as exc:
|
||||||
|
raise InvalidRelease("invalid Tauri base64 encoding") from exc
|
||||||
|
if base64.b64encode(decoded).decode("ascii") != value:
|
||||||
|
raise InvalidRelease("noncanonical Tauri base64 encoding")
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
|
def verify_signature(artifact, signature, public_key, minisign):
|
||||||
|
# Tauri wraps the entire standard minisign text file in base64.
|
||||||
|
signature_bytes = decode_tauri(signature)
|
||||||
|
key_bytes = decode_tauri(public_key)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="shacraft-update-verify-") as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
signature_path = root / "signature.minisig"
|
||||||
|
key_path = root / "public.minisign.pub"
|
||||||
|
signature_path.write_bytes(signature_bytes)
|
||||||
|
key_path.write_bytes(key_bytes)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[minisign, "-V", "-q", "-m", str(artifact), "-x", str(signature_path),
|
||||||
|
"-p", str(key_path)],
|
||||||
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL, timeout=120, check=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
|
raise InvalidRelease("minisign verification could not run") from exc
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise InvalidRelease("signature verification failed")
|
||||||
|
|
||||||
|
|
||||||
|
def bounded_command_output(command, limit=4096, timeout=10):
|
||||||
|
"""Run a fixed package inspector without shell, inherited hooks or unbounded output."""
|
||||||
|
process = None
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL, env=PACKAGE_TOOL_ENV,
|
||||||
|
)
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
output = bytearray()
|
||||||
|
with selectors.DefaultSelector() as selector:
|
||||||
|
selector.register(process.stdout, selectors.EVENT_READ)
|
||||||
|
while True:
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0 or not selector.select(remaining):
|
||||||
|
raise InvalidRelease("package inspection timed out")
|
||||||
|
chunk = os.read(process.stdout.fileno(), min(4096, limit + 1 - len(output)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
output.extend(chunk)
|
||||||
|
if len(output) > limit:
|
||||||
|
raise InvalidRelease("package inspection exceeds output limit")
|
||||||
|
returncode = process.wait(timeout=max(0.001, deadline - time.monotonic()))
|
||||||
|
if returncode != 0:
|
||||||
|
raise InvalidRelease("package inspection failed")
|
||||||
|
return bytes(output)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
|
raise InvalidRelease("package inspection could not run") from exc
|
||||||
|
finally:
|
||||||
|
if process is not None:
|
||||||
|
if process.poll() is None:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
process.stdout.close()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_artifact_format(platform, path, version):
|
||||||
|
if platform in {"linux-x86_64", "linux-x86_64-appimage"}:
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
header = stream.read(64)
|
||||||
|
if (len(header) < 64 or header[:7] != b"\x7fELF\x02\x01\x01"
|
||||||
|
or header[8:11] != b"AI\x02" or header[18:20] != b"\x3e\x00"):
|
||||||
|
raise InvalidRelease("AppImage must be a type-2 x86_64 ELF image")
|
||||||
|
elif platform == "linux-x86_64-deb":
|
||||||
|
# Inspect only authenticated package bytes; dpkg-deb does not run maintainer scripts.
|
||||||
|
output = bounded_command_output([
|
||||||
|
DPKG_DEB, "--showformat=${Package}\n${Version}\n${Architecture}\n", "--show", str(path),
|
||||||
|
])
|
||||||
|
expected = f"{DEB_PACKAGE}\n{version}\namd64\n".encode("ascii")
|
||||||
|
if output != expected:
|
||||||
|
raise InvalidRelease("deb identity must match sha-craft-launcher, signed version and amd64")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_linux_aliases(platforms):
|
||||||
|
if "linux-x86_64-appimage" in platforms or "linux-x86_64-deb" in platforms:
|
||||||
|
legacy = platforms.get("linux-x86_64")
|
||||||
|
exact = platforms.get("linux-x86_64-appimage")
|
||||||
|
if legacy is None or exact is None or legacy != exact:
|
||||||
|
raise InvalidRelease("format-aware Linux releases require identical legacy and AppImage entries")
|
||||||
|
|
||||||
|
|
||||||
|
def strict_json(data):
|
||||||
|
def unique(pairs):
|
||||||
|
result = {}
|
||||||
|
for key, value in pairs:
|
||||||
|
if key in result:
|
||||||
|
raise InvalidRelease("duplicate JSON key")
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(data, object_pairs_hook=unique)
|
||||||
|
except (ValueError, UnicodeDecodeError) as exc:
|
||||||
|
raise InvalidRelease("invalid release JSON") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(payload):
|
||||||
|
return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_payload_bytes(payload_bytes, signature, public_key, minisign):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="shacraft-update-payload-") as temporary:
|
||||||
|
immutable_payload = Path(temporary) / "payload.json"
|
||||||
|
immutable_payload.write_bytes(payload_bytes)
|
||||||
|
verify_signature(immutable_payload, signature, public_key, minisign)
|
||||||
|
|
||||||
|
|
||||||
|
def verified_previous(data, public_key, minisign):
|
||||||
|
envelope = strict_json(data)
|
||||||
|
if not isinstance(envelope, dict) or set(envelope) != FIELDS | {"signedPayload", "metadataSignature"}:
|
||||||
|
raise InvalidRelease("existing feed must have authenticated metadata")
|
||||||
|
payload_bytes = decode_tauri(envelope["signedPayload"])
|
||||||
|
verify_payload_bytes(payload_bytes, envelope["metadataSignature"], public_key, minisign)
|
||||||
|
payload = strict_json(payload_bytes)
|
||||||
|
if payload != {key: envelope[key] for key in FIELDS}:
|
||||||
|
raise InvalidRelease("existing feed fields differ from signed metadata")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def validate_payload(payload, downloads_root, public_key, minisign):
|
||||||
|
if not isinstance(payload, dict) or set(payload) != FIELDS:
|
||||||
|
raise InvalidRelease("payload must contain exactly the four Tauri release fields")
|
||||||
|
version_tuple(payload["version"])
|
||||||
|
if not isinstance(payload["notes"], str) or len(payload["notes"]) > 8000:
|
||||||
|
raise InvalidRelease("release notes must contain at most 8000 characters")
|
||||||
|
if not isinstance(payload["pub_date"], str):
|
||||||
|
raise InvalidRelease("release date must be RFC3339 UTC")
|
||||||
|
try:
|
||||||
|
datetime.strptime(payload["pub_date"], "%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
except ValueError as exc:
|
||||||
|
raise InvalidRelease("release date must be RFC3339 UTC") from exc
|
||||||
|
platforms = payload["platforms"]
|
||||||
|
if not isinstance(platforms, dict) or not platforms:
|
||||||
|
raise InvalidRelease("at least one signed updater artifact is required")
|
||||||
|
validate_linux_aliases(platforms)
|
||||||
|
release_dir = downloads_root.resolve() / payload["version"]
|
||||||
|
if release_dir.is_symlink() or not release_dir.is_dir():
|
||||||
|
raise InvalidRelease("release directory must be an existing real directory")
|
||||||
|
prefix = ORIGIN + payload["version"] + "/"
|
||||||
|
for platform, artifact in platforms.items():
|
||||||
|
if not isinstance(artifact, dict) or set(artifact) != {"url", "signature"}:
|
||||||
|
raise InvalidRelease("artifact requires exactly url and signature")
|
||||||
|
url = artifact["url"]
|
||||||
|
if not isinstance(url, str) or not url.startswith(prefix):
|
||||||
|
raise InvalidRelease("artifact must use the fixed ShaCraft release URL")
|
||||||
|
filename = artifact_name(platform, url[len(prefix):])
|
||||||
|
local_path = release_dir / filename
|
||||||
|
before = regular_file(local_path, MAX_ARTIFACT_BYTES)
|
||||||
|
verify_signature(local_path, artifact["signature"], public_key, minisign)
|
||||||
|
validate_artifact_format(platform, local_path, payload["version"])
|
||||||
|
after = regular_file(local_path, MAX_ARTIFACT_BYTES)
|
||||||
|
if (before.st_ino, before.st_size, before.st_mtime_ns) != (
|
||||||
|
after.st_ino, after.st_size, after.st_mtime_ns
|
||||||
|
):
|
||||||
|
raise InvalidRelease("artifact changed during verification")
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_write(destination, data):
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
descriptor, temporary = tempfile.mkstemp(prefix="." + destination.name + ".", dir=destination.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "wb") as stream:
|
||||||
|
os.fchmod(stream.fileno(), 0o644)
|
||||||
|
stream.write(data)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, destination)
|
||||||
|
directory = os.open(destination.parent, os.O_RDONLY | os.O_DIRECTORY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory)
|
||||||
|
finally:
|
||||||
|
os.close(directory)
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(FileNotFoundError):
|
||||||
|
os.unlink(temporary)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(args, public_key):
|
||||||
|
version_tuple(args.version)
|
||||||
|
artifacts = {}
|
||||||
|
for item in args.artifact:
|
||||||
|
platform, separator, filename = item.partition("=")
|
||||||
|
if not separator or platform in artifacts:
|
||||||
|
raise InvalidRelease("use each --artifact PLATFORM=FILENAME exactly once")
|
||||||
|
artifact_name(platform, filename)
|
||||||
|
signature = read_file(args.downloads_root / args.version / (filename + ".sig"), 16384).decode("ascii").strip()
|
||||||
|
artifacts[platform] = {
|
||||||
|
"url": ORIGIN + args.version + "/" + filename,
|
||||||
|
"signature": signature,
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"version": args.version,
|
||||||
|
"notes": read_file(args.notes_file).decode("utf-8").strip(),
|
||||||
|
"pub_date": args.pub_date or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"platforms": artifacts,
|
||||||
|
}
|
||||||
|
validate_payload(payload, args.downloads_root, public_key, args.minisign)
|
||||||
|
atomic_write(args.payload, canonical(payload))
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def publish(args, public_key):
|
||||||
|
payload_bytes = read_file(args.payload)
|
||||||
|
payload = strict_json(payload_bytes)
|
||||||
|
if not isinstance(payload, dict) or set(payload) != FIELDS:
|
||||||
|
raise InvalidRelease("payload must contain exactly the four Tauri release fields")
|
||||||
|
signature = read_file(args.signature, 16384).decode("ascii").strip()
|
||||||
|
if canonical(payload) != payload_bytes:
|
||||||
|
raise InvalidRelease("payload must be the exact canonical file from prepare")
|
||||||
|
# Verify the captured bytes, so a changing operator input cannot replace
|
||||||
|
# a verified file with different bytes in the feed.
|
||||||
|
verify_payload_bytes(payload_bytes, signature, public_key, args.minisign)
|
||||||
|
envelope = dict(payload)
|
||||||
|
envelope["signedPayload"] = base64.b64encode(payload_bytes).decode("ascii")
|
||||||
|
envelope["metadataSignature"] = signature
|
||||||
|
data = json.dumps(envelope, ensure_ascii=False, indent=2).encode("utf-8") + b"\n"
|
||||||
|
if len(data) > MAX_METADATA_BYTES:
|
||||||
|
raise InvalidRelease("signed metadata exceeds size limit")
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
lock = args.output.with_name("." + args.output.name + ".lock")
|
||||||
|
descriptor = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
||||||
|
with os.fdopen(descriptor, "wb") as lock_stream:
|
||||||
|
fcntl.flock(lock_stream, fcntl.LOCK_EX)
|
||||||
|
if args.output.exists() or args.output.is_symlink():
|
||||||
|
previous = verified_previous(read_file(args.output), public_key, args.minisign)
|
||||||
|
if version_tuple(payload["version"]) <= version_tuple(previous["version"]):
|
||||||
|
raise InvalidRelease("stable publication must strictly increase version")
|
||||||
|
validate_payload(payload, args.downloads_root, public_key, args.minisign)
|
||||||
|
if not args.dry_run:
|
||||||
|
atomic_write(args.output, data)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
common = argparse.ArgumentParser(add_help=False)
|
||||||
|
common.add_argument("--downloads-root", type=Path, required=True)
|
||||||
|
common.add_argument("--public-key", type=Path, required=True, help="Tauri outer-base64 .pub file")
|
||||||
|
common.add_argument("--minisign", default="minisign")
|
||||||
|
common.add_argument("--payload", type=Path, required=True)
|
||||||
|
commands = parser.add_subparsers(dest="command", required=True)
|
||||||
|
prepare_parser = commands.add_parser("prepare", parents=[common])
|
||||||
|
prepare_parser.add_argument("--version", required=True)
|
||||||
|
prepare_parser.add_argument("--artifact", action="append", required=True, metavar="PLATFORM=FILENAME")
|
||||||
|
prepare_parser.add_argument("--notes-file", type=Path, required=True)
|
||||||
|
prepare_parser.add_argument("--pub-date")
|
||||||
|
publish_parser = commands.add_parser("publish", parents=[common])
|
||||||
|
publish_parser.add_argument("--signature", type=Path, required=True)
|
||||||
|
publish_parser.add_argument("--output", type=Path, required=True)
|
||||||
|
publish_parser.add_argument("--dry-run", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
public_key = read_file(args.public_key, 16384).decode("ascii").strip()
|
||||||
|
payload = prepare(args, public_key) if args.command == "prepare" else publish(args, public_key)
|
||||||
|
except (InvalidRelease, OSError, UnicodeError) as exc:
|
||||||
|
parser.exit(1, f"Release rejected: {exc}\n")
|
||||||
|
print(f"{args.command}: {payload['version']} ({', '.join(sorted(payload['platforms']))})")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1 +0,0 @@
|
|||||||
/target/
|
|
||||||
Generated
-23
@@ -1,23 +0,0 @@
|
|||||||
# 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",
|
|
||||||
]
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "shacraft-release-verifier"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
publish = false
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
base64 = "=0.22.1"
|
|
||||||
minisign-verify = "=0.2.5"
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
//! 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<dyn std::error::Error>> {
|
|
||||||
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<String, Box<dyn std::error::Error>> {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
"""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()
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
"""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("<I", data, 60)[0]
|
|
||||||
require(
|
|
||||||
64 <= offset <= len(data) - 26 and data[offset : offset + 4] == b"PE\0\0",
|
|
||||||
"invalid PE header",
|
|
||||||
)
|
|
||||||
return struct.unpack_from("<H", data, offset + 4)[0], struct.unpack_from(
|
|
||||||
"<H", data, offset + 24
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
|
|
||||||
def pe_x64(data):
|
|
||||||
require(pe_machine(data) == (0x8664, 0x20B), "launcher executable must be AMD64 PE32+")
|
|
||||||
|
|
||||||
|
|
||||||
def appimage(path):
|
|
||||||
with path.open("rb") as stream:
|
|
||||||
data = stream.read(64)
|
|
||||||
require(
|
|
||||||
len(data) == 64 and data[:6] == b"\x7fELF\x02\x01", "AppImage must be little-endian ELF64"
|
|
||||||
)
|
|
||||||
require(data[8:11] == b"AI\x02", "AppImage must use Type 2 format")
|
|
||||||
require(struct.unpack_from("<H", data, 18)[0] == 62, "AppImage must target AMD64")
|
|
||||||
|
|
||||||
|
|
||||||
def safe_member(name):
|
|
||||||
name = name.removeprefix("./")
|
|
||||||
path = PurePosixPath(name)
|
|
||||||
require(
|
|
||||||
not path.is_absolute() and ".." not in path.parts and "\\" not in name,
|
|
||||||
"unsafe archive member",
|
|
||||||
)
|
|
||||||
return name
|
|
||||||
|
|
||||||
|
|
||||||
def mac_app(path, platform, version):
|
|
||||||
with tarfile.open(path, "r:gz") as archive:
|
|
||||||
members = {}
|
|
||||||
for index, item in enumerate(archive):
|
|
||||||
require(index < 10000, "too many app archive entries")
|
|
||||||
name = safe_member(item.name).rstrip("/")
|
|
||||||
require(name not in members, "duplicate app archive member")
|
|
||||||
members[name] = item
|
|
||||||
plists = [name for name in members if name.endswith(".app/Contents/Info.plist")]
|
|
||||||
require(len(plists) == 1, "expected exactly one app Info.plist")
|
|
||||||
info = members[plists[0]]
|
|
||||||
require(info.isfile() and 0 < info.size <= 1024 * 1024, "invalid app Info.plist")
|
|
||||||
details = plistlib.loads(archive.extractfile(info).read())
|
|
||||||
executable = details.get("CFBundleExecutable", "")
|
|
||||||
require(
|
|
||||||
isinstance(executable, str)
|
|
||||||
and executable not in {"", ".", ".."}
|
|
||||||
and "/" not in executable
|
|
||||||
and "\\" not in executable,
|
|
||||||
"invalid app executable name",
|
|
||||||
)
|
|
||||||
require(details.get("CFBundleShortVersionString") == version, "app version mismatch")
|
|
||||||
main = plists[0].removesuffix("Info.plist") + "MacOS/" + executable
|
|
||||||
require(main in members and members[main].isfile(), "app executable missing or linked")
|
|
||||||
data = archive.extractfile(members[main]).read(32)
|
|
||||||
require(
|
|
||||||
len(data) == 32 and data[:4] == b"\xcf\xfa\xed\xfe",
|
|
||||||
"expected a thin little-endian Mach-O64 executable",
|
|
||||||
)
|
|
||||||
cpu, _, kind = struct.unpack_from("<III", data, 4)
|
|
||||||
wanted = 0x0100000C if platform == "darwin-aarch64" else 0x01000007
|
|
||||||
require(cpu == wanted and kind == 2, "app Mach-O architecture/type mismatch")
|
|
||||||
|
|
||||||
|
|
||||||
def deb(path, version):
|
|
||||||
control = None
|
|
||||||
debian_binary = None
|
|
||||||
with path.open("rb") as stream:
|
|
||||||
require(stream.read(8) == b"!<arch>\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)
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
"""Execute the actual trusted workflow gate against disposable local Git history."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
|
|
||||||
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 gate_bash(windows=os.name == "nt"):
|
|
||||||
# Native CreateProcess can resolve bare bash to the System32 WSL launcher
|
|
||||||
# before Git Bash. Bind tests to the Bash installed with the Git we use.
|
|
||||||
if windows:
|
|
||||||
git = shutil.which("git")
|
|
||||||
if git:
|
|
||||||
directory = Path(git).resolve().parent
|
|
||||||
candidates = (
|
|
||||||
directory / "bash.exe", # Git/bin/git.exe
|
|
||||||
directory.parent / "bin/bash.exe", # Git/cmd/git.exe
|
|
||||||
directory.parent.parent / "bin/bash.exe", # Git/mingw64/bin/git.exe
|
|
||||||
)
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.is_file():
|
|
||||||
return str(candidate.resolve())
|
|
||||||
raise RuntimeError("Git for Windows Bash was not found beside the installed Git")
|
|
||||||
bash = shutil.which("bash")
|
|
||||||
if not bash:
|
|
||||||
raise RuntimeError("Bash is required to execute the release workflow gate")
|
|
||||||
return str(Path(bash).resolve())
|
|
||||||
|
|
||||||
|
|
||||||
def gate_diagnostic(result):
|
|
||||||
return f"command={result.args!r}\nstdout={result.stdout!r}\nstderr={result.stderr!r}"
|
|
||||||
|
|
||||||
|
|
||||||
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([gate_bash(), "--noprofile", "--norc", "-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_windows_gate_uses_git_bash_even_when_path_contains_wsl_launcher(self):
|
|
||||||
git_root = self.directory / "Git with spaces"
|
|
||||||
bash = git_root / "bin/bash.exe"
|
|
||||||
bash.parent.mkdir(parents=True)
|
|
||||||
bash.touch()
|
|
||||||
wsl = self.directory / "System32/bash.exe"
|
|
||||||
wsl.parent.mkdir()
|
|
||||||
wsl.touch()
|
|
||||||
for layout in ("bin", "cmd", "mingw64/bin"):
|
|
||||||
with self.subTest(layout=layout):
|
|
||||||
git = git_root / layout / "git.exe"
|
|
||||||
git.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
git.touch()
|
|
||||||
with patch.object(shutil, "which", side_effect=lambda name: str(git if name == "git" else wsl)) as lookup:
|
|
||||||
self.assertEqual(gate_bash(windows=True), str(bash.resolve()))
|
|
||||||
lookup.assert_called_once_with("git")
|
|
||||||
|
|
||||||
def test_windows_gate_never_falls_back_to_unrelated_bash(self):
|
|
||||||
git = self.directory / "Git/cmd/git.exe"
|
|
||||||
git.parent.mkdir(parents=True)
|
|
||||||
git.touch()
|
|
||||||
wsl = self.directory / "System32/bash.exe"
|
|
||||||
wsl.parent.mkdir()
|
|
||||||
wsl.touch()
|
|
||||||
with patch.object(shutil, "which", side_effect=lambda name: str(git if name == "git" else wsl)) as lookup:
|
|
||||||
with self.assertRaisesRegex(RuntimeError, "Git for Windows Bash"):
|
|
||||||
gate_bash(windows=True)
|
|
||||||
lookup.assert_called_once_with("git")
|
|
||||||
|
|
||||||
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, gate_diagnostic(result))
|
|
||||||
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, gate_diagnostic(result))
|
|
||||||
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, gate_diagnostic(result))
|
|
||||||
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, gate_diagnostic(result))
|
|
||||||
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, gate_diagnostic(result))
|
|
||||||
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()
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,531 +0,0 @@
|
|||||||
"""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("<I", data, 60, 128)
|
|
||||||
data[128:132] = b"PE\0\0"
|
|
||||||
struct.pack_into("<H", data, 132, machine)
|
|
||||||
struct.pack_into("<H", data, 152, magic)
|
|
||||||
marker = b"__TAURI_BUNDLE_TYPE_VAR_UNK"
|
|
||||||
data[200 : 200 + len(marker)] = marker
|
|
||||||
return bytes(data)
|
|
||||||
|
|
||||||
|
|
||||||
def fixture_tar(files):
|
|
||||||
stream = io.BytesIO()
|
|
||||||
with tarfile.open(fileobj=stream, mode="w:gz") as archive:
|
|
||||||
for name, data in files.items():
|
|
||||||
info = tarfile.TarInfo(name)
|
|
||||||
info.size = len(data)
|
|
||||||
archive.addfile(info, io.BytesIO(data))
|
|
||||||
return stream.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
def fixture_deb(architecture="amd64", version="0.3.0"):
|
|
||||||
control = fixture_tar(
|
|
||||||
{
|
|
||||||
"./control": f"Package: shacraft-launcher\nVersion: {version}\nArchitecture: {architecture}\n".encode()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result = bytearray(b"!<arch>\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("<IIIIIIII", 0xFEEDFACF, cpu, 0, 2, 0, 0, 0, 0)
|
|
||||||
return fixture_tar(
|
|
||||||
{
|
|
||||||
"ShaCraft.app/Contents/Info.plist": plistlib.dumps(
|
|
||||||
{
|
|
||||||
"CFBundleExecutable": "shacraft-launcher",
|
|
||||||
"CFBundleShortVersionString": version,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
"ShaCraft.app/Contents/MacOS/shacraft-launcher": executable,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if name.endswith(".AppImage"):
|
|
||||||
data = bytearray(64)
|
|
||||||
data[:6] = b"\x7fELF\x02\x01"
|
|
||||||
data[8:11] = b"AI\x02"
|
|
||||||
struct.pack_into("<H", data, 18, 62)
|
|
||||||
return bytes(data)
|
|
||||||
if name.endswith(".deb"):
|
|
||||||
return fixture_deb(version=version)
|
|
||||||
if name.endswith(".dmg"):
|
|
||||||
return b"koly" + bytes(508)
|
|
||||||
if name.endswith(".msi"):
|
|
||||||
data = bytearray(512)
|
|
||||||
data[:8] = bytes.fromhex("d0cf11e0a1b11ae1")
|
|
||||||
data[28:30] = b"\xfe\xff"
|
|
||||||
return bytes(data)
|
|
||||||
return fixture_pe(0x14C, 0x10B) # x86 NSIS wrapper is valid for an x64 payload.
|
|
||||||
|
|
||||||
|
|
||||||
class ReleaseTests(unittest.TestCase):
|
|
||||||
@classmethod
|
|
||||||
def setUpClass(cls):
|
|
||||||
cls.temporary = tempfile.TemporaryDirectory(prefix="shacraft-release-tests-")
|
|
||||||
cls.base = Path(cls.temporary.name)
|
|
||||||
# A new unencrypted disposable key per test process. No static private
|
|
||||||
# key or credentials are stored in the repository or test output.
|
|
||||||
cls.key = cls.base / "test-only.key"
|
|
||||||
environment = dict(os.environ)
|
|
||||||
for name in (
|
|
||||||
"TAURI_SIGNING_PRIVATE_KEY",
|
|
||||||
"TAURI_SIGNING_PRIVATE_KEY_PATH",
|
|
||||||
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
|
|
||||||
):
|
|
||||||
environment.pop(name, None)
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
"node",
|
|
||||||
str(ROOT / "node_modules/@tauri-apps/cli/tauri.js"),
|
|
||||||
"signer",
|
|
||||||
"generate",
|
|
||||||
"--ci",
|
|
||||||
"--password",
|
|
||||||
"",
|
|
||||||
"--write-keys",
|
|
||||||
str(cls.key),
|
|
||||||
],
|
|
||||||
env=environment,
|
|
||||||
capture_output=True,
|
|
||||||
timeout=120,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode:
|
|
||||||
raise RuntimeError("disposable Tauri signer setup failed")
|
|
||||||
cls.key.chmod(0o600)
|
|
||||||
cls.key_values = {
|
|
||||||
"SHACRAFT_UPDATER_PUBLIC_KEY": Path(str(cls.key) + ".pub").read_text().strip(),
|
|
||||||
"TAURI_SIGNING_PRIVATE_KEY": cls.key.read_text().strip(),
|
|
||||||
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD": "",
|
|
||||||
"SHACRAFT_UPDATER_TEST_BUILD": "0",
|
|
||||||
}
|
|
||||||
cls.originals = cls.base / "complete"
|
|
||||||
cls.originals.mkdir()
|
|
||||||
with patch.dict(os.environ, cls.key_values):
|
|
||||||
for name in release.expected_names("0.3.0"):
|
|
||||||
path = cls.originals / name
|
|
||||||
path.write_bytes(fixture_package(name))
|
|
||||||
release.signer(ROOT, path)
|
|
||||||
data = release.metadata(
|
|
||||||
ROOT, cls.originals, "0.3.0", "v0.3.0", "Test only", "2026-09-09T00:00:00Z"
|
|
||||||
)
|
|
||||||
(cls.originals / "latest.json").write_bytes(release.canonical(data))
|
|
||||||
release.signer(ROOT, cls.originals / "latest.json")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def tearDownClass(cls):
|
|
||||||
cls.temporary.cleanup()
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.case = tempfile.TemporaryDirectory(dir=self.base)
|
|
||||||
self.directory = Path(self.case.name) / "assets"
|
|
||||||
shutil.copytree(self.originals, self.directory)
|
|
||||||
self.environment = patch.dict(os.environ, self.key_values)
|
|
||||||
self.environment.start()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.environment.stop()
|
|
||||||
self.case.cleanup()
|
|
||||||
|
|
||||||
def verify(self):
|
|
||||||
release.verify_release(ROOT, self.directory, "0.3.0", "v0.3.0")
|
|
||||||
|
|
||||||
def resign_metadata(self, mutate):
|
|
||||||
path = self.directory / "latest.json"
|
|
||||||
data = json.loads(path.read_bytes())
|
|
||||||
mutate(data)
|
|
||||||
path.write_bytes(release.canonical(data))
|
|
||||||
release.signer(ROOT, path)
|
|
||||||
|
|
||||||
def test_complete_release_and_metadata_generation_are_deterministic(self):
|
|
||||||
self.verify()
|
|
||||||
first = release.metadata(
|
|
||||||
ROOT, self.directory, "0.3.0", "v0.3.0", "Test only", "2026-09-09T00:00:00Z"
|
|
||||||
)
|
|
||||||
self.assertEqual(release.canonical(first), (self.directory / "latest.json").read_bytes())
|
|
||||||
|
|
||||||
def test_package_bit_flip_fails_actual_plugin_verification(self):
|
|
||||||
path = self.directory / release.filename("0.3.0", "linux-x86_64", ".AppImage")
|
|
||||||
path.write_bytes(path.read_bytes() + b"tampered")
|
|
||||||
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
|
|
||||||
self.verify()
|
|
||||||
|
|
||||||
def test_metadata_substitution_is_rejected_before_json_parsing(self):
|
|
||||||
(self.directory / "latest.json").write_bytes(b"not even JSON")
|
|
||||||
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
|
|
||||||
self.verify()
|
|
||||||
|
|
||||||
def test_valid_signature_for_other_artifact_does_not_suffice(self):
|
|
||||||
linux = release.filename("0.3.0", "linux-x86_64", ".AppImage")
|
|
||||||
windows = release.filename("0.3.0", "windows-x86_64", "-setup.exe")
|
|
||||||
shutil.copyfile(self.directory / (windows + ".sig"), self.directory / (linux + ".sig"))
|
|
||||||
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
|
|
||||||
self.verify()
|
|
||||||
|
|
||||||
def test_wrong_public_key_fails_real_verifier(self):
|
|
||||||
encoded = release.public_key()
|
|
||||||
import base64
|
|
||||||
|
|
||||||
lines = base64.b64decode(encoded).decode().splitlines()
|
|
||||||
raw = bytearray(base64.b64decode(lines[1]))
|
|
||||||
raw[-1] ^= 1
|
|
||||||
lines[1] = base64.b64encode(raw).decode()
|
|
||||||
wrong = base64.b64encode(("\n".join(lines) + "\n").encode()).decode()
|
|
||||||
with patch.dict(os.environ, {"SHACRAFT_UPDATER_PUBLIC_KEY": wrong}):
|
|
||||||
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
|
|
||||||
self.verify()
|
|
||||||
|
|
||||||
def test_all_platforms_and_manual_packages_are_mandatory(self):
|
|
||||||
for name in sorted(release.expected_names("0.3.0")):
|
|
||||||
path = self.directory / (name + ".sig")
|
|
||||||
original = path.read_bytes()
|
|
||||||
path.unlink()
|
|
||||||
with self.subTest(name=name), self.assertRaisesRegex(ValueError, "asset set"):
|
|
||||||
self.verify()
|
|
||||||
path.write_bytes(original)
|
|
||||||
|
|
||||||
def test_extra_ci_marker_or_file_blocks_promotion(self):
|
|
||||||
(self.directory / "CI_NOT_FOR_RELEASE.txt").write_text("test key")
|
|
||||||
with self.assertRaisesRegex(ValueError, "asset set"):
|
|
||||||
self.verify()
|
|
||||||
|
|
||||||
def test_signed_metadata_must_match_tag_and_exact_source_assets(self):
|
|
||||||
alterations = [
|
|
||||||
lambda x: x.update(version="0.2.0"),
|
|
||||||
lambda x: x.update(tag="v0.4.0"),
|
|
||||||
lambda x: x["platforms"].pop("darwin-aarch64"),
|
|
||||||
lambda x: x["platforms"].update({"linux-aarch64": x["platforms"]["linux-x86_64"]}),
|
|
||||||
lambda x: x["manualPackages"].pop("windows-x86_64-msi"),
|
|
||||||
lambda x: x["platforms"]["linux-x86_64"].update(url="https://evil.invalid/package"),
|
|
||||||
lambda x: x["platforms"]["linux-x86_64"].update(
|
|
||||||
url=x["platforms"]["linux-x86_64"]["url"].replace("v0.3.0", "v0.2.0")
|
|
||||||
),
|
|
||||||
lambda x: x["platforms"]["linux-x86_64"].update(sha256="0" * 64),
|
|
||||||
lambda x: x["platforms"]["linux-x86_64"].update(size=True),
|
|
||||||
lambda x: x.update(pub_date="2026-09-09T00:00:00+03:00"),
|
|
||||||
lambda x: x.update(extra="not allowed"),
|
|
||||||
]
|
|
||||||
for index, alter in enumerate(alterations):
|
|
||||||
shutil.copyfile(self.originals / "latest.json", self.directory / "latest.json")
|
|
||||||
self.resign_metadata(alter)
|
|
||||||
with self.subTest(index=index), self.assertRaises(ValueError):
|
|
||||||
self.verify()
|
|
||||||
|
|
||||||
def test_duplicate_json_fields_are_rejected_even_if_signed(self):
|
|
||||||
path = self.directory / "latest.json"
|
|
||||||
path.write_bytes(
|
|
||||||
path.read_bytes().replace(
|
|
||||||
b'"schemaVersion": 1,', b'"schemaVersion": 1, "schemaVersion": 1,'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
release.signer(ROOT, path)
|
|
||||||
with self.assertRaisesRegex(ValueError, "duplicate JSON"):
|
|
||||||
self.verify()
|
|
||||||
|
|
||||||
def test_collect_requires_generated_updater_sig_and_signs_manual_deb(self):
|
|
||||||
bundle = Path(self.case.name) / "bundle"
|
|
||||||
(bundle / "appimage").mkdir(parents=True)
|
|
||||||
(bundle / "deb").mkdir()
|
|
||||||
app = bundle / "appimage/Test.AppImage"
|
|
||||||
app.write_bytes(fixture_package("Test.AppImage"))
|
|
||||||
(bundle / "deb/Test.deb").write_bytes(fixture_package("Test.deb"))
|
|
||||||
output = Path(self.case.name) / "collected"
|
|
||||||
with self.assertRaisesRegex(ValueError, "missing generated updater signature"):
|
|
||||||
release.collect(ROOT, bundle, output, "linux-x86_64", "0.3.0")
|
|
||||||
shutil.rmtree(output)
|
|
||||||
release.signer(ROOT, app)
|
|
||||||
release.collect(ROOT, bundle, output, "linux-x86_64", "0.3.0")
|
|
||||||
self.assertEqual(len(list(output.iterdir())), 4)
|
|
||||||
for suffix in (".AppImage", ".deb"):
|
|
||||||
data = output / release.filename("0.3.0", "linux-x86_64", suffix)
|
|
||||||
release.verify_signature(ROOT, data, Path(str(data) + ".sig"))
|
|
||||||
|
|
||||||
def test_missing_key_fails_and_optional_password_never_prompts(self):
|
|
||||||
data = self.directory / "latest.json"
|
|
||||||
with patch.dict(os.environ, {"TAURI_SIGNING_PRIVATE_KEY": ""}):
|
|
||||||
with self.assertRaisesRegex(ValueError, "PRIVATE_KEY is missing"):
|
|
||||||
release.signer(ROOT, data)
|
|
||||||
with patch.dict(os.environ):
|
|
||||||
os.environ.pop("TAURI_SIGNING_PRIVATE_KEY_PASSWORD", None)
|
|
||||||
release.signer(ROOT, data)
|
|
||||||
release.verify_signature(ROOT, data, Path(str(data) + ".sig"))
|
|
||||||
|
|
||||||
def test_release_preflight_rejects_placeholder_mismatch_and_test_mode(self):
|
|
||||||
source = Path(self.case.name) / "source"
|
|
||||||
(source / "src-tauri").mkdir(parents=True)
|
|
||||||
(source / "package.json").write_text('{"version":"0.3.0"}')
|
|
||||||
(source / "package-lock.json").write_text(
|
|
||||||
'{"version":"0.3.0","packages":{"":{"version":"0.3.0"}}}'
|
|
||||||
)
|
|
||||||
(source / "src-tauri/tauri.conf.json").write_text('{"version":"0.3.0"}')
|
|
||||||
(source / "src-tauri/Cargo.toml").write_text('[package]\nversion="0.3.0"\n')
|
|
||||||
(source / "src-tauri/updater-public-key.txt").write_text("UNCONFIGURED")
|
|
||||||
with self.assertRaisesRegex(ValueError, "differs from committed"):
|
|
||||||
release.preflight(source, "0.3.0", "v0.3.0", signing=True)
|
|
||||||
with patch.dict(os.environ, {"SHACRAFT_UPDATER_TEST_BUILD": "1"}):
|
|
||||||
with self.assertRaisesRegex(ValueError, "test keys cannot release"):
|
|
||||||
release.preflight(source, "0.3.0", "v0.3.0", signing=True)
|
|
||||||
(source / "package.json").write_text('{"version":"0.3.1"}')
|
|
||||||
with self.assertRaisesRegex(ValueError, "source versions disagree"):
|
|
||||||
release.preflight(source, "0.3.0", "v0.3.0")
|
|
||||||
|
|
||||||
|
|
||||||
class PackageFormatTests(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.temporary = tempfile.TemporaryDirectory(prefix="shacraft-format-tests-")
|
|
||||||
self.root = Path(self.temporary.name)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.temporary.cleanup()
|
|
||||||
|
|
||||||
def package(self, name, data=None):
|
|
||||||
path = self.root / name
|
|
||||||
path.write_bytes(fixture_package(name) if data is None else data)
|
|
||||||
return path
|
|
||||||
|
|
||||||
def test_mac_archive_binds_main_executable_cpu_and_version(self):
|
|
||||||
path = self.package("darwin-aarch64.app.tar.gz")
|
|
||||||
release_formats.mac_app(path, "darwin-aarch64", "0.3.0")
|
|
||||||
for platform, version in [("darwin-x86_64", "0.3.0"), ("darwin-aarch64", "0.2.0")]:
|
|
||||||
with self.subTest(platform=platform, version=version), self.assertRaises(ValueError):
|
|
||||||
release_formats.mac_app(path, platform, version)
|
|
||||||
|
|
||||||
def test_mac_archive_rejects_traversal_and_missing_binary(self):
|
|
||||||
for files in (
|
|
||||||
{"../Bad.app/Contents/Info.plist": b"bad"},
|
|
||||||
{
|
|
||||||
"ShaCraft.app/Contents/Info.plist": plistlib.dumps(
|
|
||||||
{"CFBundleExecutable": "missing", "CFBundleShortVersionString": "0.3.0"}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
):
|
|
||||||
path = self.package("darwin-aarch64.app.tar.gz", fixture_tar(files))
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
release_formats.mac_app(path, "darwin-aarch64", "0.3.0")
|
|
||||||
|
|
||||||
def test_appimage_rejects_arm64_wrong_class_and_type1(self):
|
|
||||||
path = self.package("linux-x86_64.AppImage")
|
|
||||||
release_formats.appimage(path)
|
|
||||||
for offset, data in [(18, struct.pack("<H", 183)), (4, b"\x01"), (10, b"\x01")]:
|
|
||||||
bad = bytearray(fixture_package(path.name))
|
|
||||||
bad[offset : offset + len(data)] = data
|
|
||||||
path.write_bytes(bad)
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
release_formats.appimage(path)
|
|
||||||
|
|
||||||
def test_deb_control_binds_architecture_and_version(self):
|
|
||||||
path = self.package("linux-x86_64.deb")
|
|
||||||
release_formats.deb(path, "0.3.0")
|
|
||||||
for architecture, version in [("arm64", "0.3.0"), ("amd64", "0.2.0")]:
|
|
||||||
path.write_bytes(fixture_deb(architecture, version))
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
release_formats.deb(path, "0.3.0")
|
|
||||||
|
|
||||||
def test_nsis_stub_may_be_x86_but_extracted_main_must_equal_x64_build(self):
|
|
||||||
wrapper = self.package("setup.exe")
|
|
||||||
main = self.package("shacraft-launcher.exe", fixture_pe())
|
|
||||||
listing = subprocess.CompletedProcess(
|
|
||||||
[], 0, b"Path = setup.exe\nPath = $INSTDIR/shacraft-launcher.exe\n", b""
|
|
||||||
)
|
|
||||||
for extracted, success in [
|
|
||||||
(release_formats.expected_nsis_payload(fixture_pe()), True),
|
|
||||||
(fixture_pe(), False),
|
|
||||||
(release_formats.expected_nsis_payload(fixture_pe(0xAA64, 0x20B)), False),
|
|
||||||
(release_formats.expected_nsis_payload(fixture_pe()) + b"different", False),
|
|
||||||
]:
|
|
||||||
with (
|
|
||||||
patch.object(release_formats.shutil, "which", return_value=str(main)),
|
|
||||||
patch.object(
|
|
||||||
release_formats.subprocess,
|
|
||||||
"run",
|
|
||||||
side_effect=[listing, subprocess.CompletedProcess([], 0, extracted, b"")],
|
|
||||||
),
|
|
||||||
):
|
|
||||||
if success:
|
|
||||||
release_formats.windows(wrapper, "-setup.exe", "0.3.0", main)
|
|
||||||
else:
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
release_formats.windows(wrapper, "-setup.exe", "0.3.0", main)
|
|
||||||
|
|
||||||
def test_nsis_identity_allows_only_the_actual_first_bundle_marker_patch(self):
|
|
||||||
original = fixture_pe() + b"__TAURI_BUNDLE_TYPE_VAR_UNK"
|
|
||||||
expected = release_formats.expected_nsis_payload(original)
|
|
||||||
self.assertEqual(
|
|
||||||
expected,
|
|
||||||
original.replace(b"__TAURI_BUNDLE_TYPE_VAR_UNK", b"__TAURI_BUNDLE_TYPE_VAR_NSS", 1),
|
|
||||||
)
|
|
||||||
self.assertEqual(expected.count(b"__TAURI_BUNDLE_TYPE_VAR_UNK"), 1)
|
|
||||||
with self.assertRaisesRegex(ValueError, "bundle-type marker"):
|
|
||||||
release_formats.expected_nsis_payload(
|
|
||||||
fixture_pe().replace(b"__TAURI_BUNDLE_TYPE_VAR_UNK", b"__TAURI_BUNDLE_TYPE_VAR_MSI")
|
|
||||||
)
|
|
||||||
# No certificate table/checksum/other byte range is ignored.
|
|
||||||
mutated = bytearray(expected)
|
|
||||||
mutated[190] ^= 1
|
|
||||||
self.assertNotEqual(bytes(mutated), release_formats.expected_nsis_payload(original))
|
|
||||||
self.assertNotEqual(
|
|
||||||
expected.replace(b"_VAR_NSS", b"_VAR_MSI"),
|
|
||||||
release_formats.expected_nsis_payload(original),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_msi_readonly_metadata_requires_x64_and_version(self):
|
|
||||||
msi = self.package("setup.msi")
|
|
||||||
main = self.package("shacraft-launcher.exe", fixture_pe())
|
|
||||||
for architecture, version, success in [
|
|
||||||
("x64;1033", "0.3.0", True),
|
|
||||||
("Intel;1033", "0.3.0", False),
|
|
||||||
("x64;1033", "0.2.0", False),
|
|
||||||
]:
|
|
||||||
result = subprocess.CompletedProcess(
|
|
||||||
[], 0, json.dumps({"template": architecture, "version": version}).encode(), b""
|
|
||||||
)
|
|
||||||
with patch.object(release_formats.subprocess, "run", return_value=result) as command:
|
|
||||||
if success:
|
|
||||||
release_formats.windows(msi, ".msi", "0.3.0", main)
|
|
||||||
else:
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
release_formats.windows(msi, ".msi", "0.3.0", main)
|
|
||||||
self.assertIn("release_msi.ps1", command.call_args.args[0][4])
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowPolicyTests(unittest.TestCase):
|
|
||||||
def protected(self):
|
|
||||||
return {
|
|
||||||
"deployment_branch_policy": {
|
|
||||||
"protected_branches": True,
|
|
||||||
"custom_branch_policies": False,
|
|
||||||
},
|
|
||||||
"protection_rules": [
|
|
||||||
{
|
|
||||||
"type": "required_reviewers",
|
|
||||||
"prevent_self_review": True,
|
|
||||||
"reviewers": [{"type": "User", "reviewer": {"id": 123}}],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_protected_environment_requires_existing_independent_review(self):
|
|
||||||
release_github.validate_environment(self.protected())
|
|
||||||
for data in (
|
|
||||||
None,
|
|
||||||
{},
|
|
||||||
{"deployment_branch_policy": {}},
|
|
||||||
dict(self.protected(), protection_rules=[]),
|
|
||||||
):
|
|
||||||
with self.subTest(data=data), self.assertRaises(ValueError):
|
|
||||||
release_github.validate_environment(data)
|
|
||||||
for mutate in (
|
|
||||||
lambda d: d["deployment_branch_policy"].update(protected_branches=False),
|
|
||||||
lambda d: d["protection_rules"][0].update(prevent_self_review=False),
|
|
||||||
lambda d: d["protection_rules"][0].update(reviewers=[]),
|
|
||||||
):
|
|
||||||
data = self.protected()
|
|
||||||
mutate(data)
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
release_github.validate_environment(data)
|
|
||||||
|
|
||||||
def test_gate_does_not_emit_missing_environment_name(self):
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
output = Path(directory) / "output"
|
|
||||||
env = {
|
|
||||||
"GITHUB_REPOSITORY": release.REPOSITORY,
|
|
||||||
"GITHUB_EVENT_NAME": "workflow_dispatch",
|
|
||||||
"GITHUB_REF": "refs/heads/main",
|
|
||||||
"GITHUB_OUTPUT": str(output),
|
|
||||||
}
|
|
||||||
with (
|
|
||||||
patch.dict(os.environ, env),
|
|
||||||
patch.object(release_github, "api", side_effect=[{"protected": True}, None]),
|
|
||||||
):
|
|
||||||
with self.assertRaisesRegex(ValueError, "absent"):
|
|
||||||
release_github.gate("launcher-release")
|
|
||||||
self.assertFalse(output.exists())
|
|
||||||
|
|
||||||
def test_publication_never_runs_without_exact_confirmation(self):
|
|
||||||
env = {
|
|
||||||
"GITHUB_REPOSITORY": release.REPOSITORY,
|
|
||||||
"GITHUB_EVENT_NAME": "workflow_dispatch",
|
|
||||||
"GITHUB_REF": "refs/heads/main",
|
|
||||||
}
|
|
||||||
with patch.dict(os.environ, env), patch.object(release_github, "gh") as client:
|
|
||||||
with self.assertRaisesRegex(ValueError, "confirmation"):
|
|
||||||
release_github.publish(ROOT, "0.3.0", "v0.3.0", "publish v0.2.0")
|
|
||||||
client.assert_not_called()
|
|
||||||
|
|
||||||
def test_asset_snapshot_rejects_public_or_incomplete_draft(self):
|
|
||||||
data = {
|
|
||||||
"draft": True,
|
|
||||||
"prerelease": False,
|
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "latest.json",
|
|
||||||
"size": 10,
|
|
||||||
"state": "uploaded",
|
|
||||||
"digest": "sha256:abc",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
self.assertEqual(release_github.asset_snapshot(data)["latest.json"][0], 1)
|
|
||||||
for change in (
|
|
||||||
{"draft": False},
|
|
||||||
{"prerelease": True},
|
|
||||||
{"assets": [dict(data["assets"][0], state="new")]},
|
|
||||||
{"assets": data["assets"] * 2},
|
|
||||||
):
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
release_github.asset_snapshot(dict(data, **change))
|
|
||||||
|
|
||||||
def test_version_and_tag_cannot_inject_paths_or_exceed_msi_limits(self):
|
|
||||||
release.version_tag("0.3.0", "v0.3.0")
|
|
||||||
for version, tag in (
|
|
||||||
("0.3.0", "main"),
|
|
||||||
("0.3.0", "v0.3.0/evil"),
|
|
||||||
("01.3.0", "v01.3.0"),
|
|
||||||
("0.3.0-beta", "v0.3.0-beta"),
|
|
||||||
("256.0.0", "v256.0.0"),
|
|
||||||
("0.0.65536", "v0.0.65536"),
|
|
||||||
):
|
|
||||||
with self.subTest(version=version, tag=tag), self.assertRaises(ValueError):
|
|
||||||
release.version_tag(version, tag)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"bundle": {
|
||||||
|
"createUpdaterArtifacts": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""Publisher policy and real minisign verification; keys exist only in tempdirs."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import copy
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import publish_launcher_update as publisher
|
||||||
|
|
||||||
|
MINISIGN = os.environ.get("SHACRAFT_TEST_MINISIGN", "minisign")
|
||||||
|
|
||||||
|
|
||||||
|
def appimage_fixture():
|
||||||
|
header = bytearray(64)
|
||||||
|
header[:7] = b"\x7fELF\x02\x01\x01"
|
||||||
|
header[8:11] = b"AI\x02"
|
||||||
|
header[18:20] = b"\x3e\x00"
|
||||||
|
return bytes(header) + b"isolated format fixture; not a runnable launcher"
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyTests(unittest.TestCase):
|
||||||
|
def test_stable_versions_are_strict_and_order_numerically(self):
|
||||||
|
self.assertGreater(publisher.version_tuple("0.1.10"), publisher.version_tuple("0.1.9"))
|
||||||
|
for value in ("v0.1.3", "0.01.3", "0.1.3-beta", "0.1.3+build", "../0.1.3", 3):
|
||||||
|
with self.subTest(value=value), self.assertRaises(publisher.InvalidRelease):
|
||||||
|
publisher.version_tuple(value)
|
||||||
|
|
||||||
|
def test_platform_filename_policy(self):
|
||||||
|
publisher.artifact_name("linux-x86_64", "ShaCraft.Launcher_0.1.3_amd64.AppImage")
|
||||||
|
publisher.artifact_name("linux-x86_64-appimage", "ShaCraft.Launcher_0.1.4_amd64.AppImage")
|
||||||
|
publisher.artifact_name("linux-x86_64-deb", "ShaCraft.Launcher_0.1.4_amd64.deb")
|
||||||
|
for platform, filename in (
|
||||||
|
("linux-x86_64", "../bad.AppImage"), ("linux-x86_64", "foo.AppImage?secret"),
|
||||||
|
("linux-x86_64", "%2e%2e.AppImage"), ("linux-x86_64", "install.exe"),
|
||||||
|
("unknown", "test.AppImage"), ("darwin-aarch64", "installer.dmg"),
|
||||||
|
("linux-x86_64", "install.deb"), ("linux-x86_64-appimage", "install.deb"),
|
||||||
|
("linux-x86_64-deb", "install.AppImage"),
|
||||||
|
):
|
||||||
|
with self.subTest(filename=filename), self.assertRaises(publisher.InvalidRelease):
|
||||||
|
publisher.artifact_name(platform, filename)
|
||||||
|
|
||||||
|
def test_duplicate_json_keys_are_rejected(self):
|
||||||
|
with self.assertRaises(publisher.InvalidRelease):
|
||||||
|
publisher.strict_json(b'{"version":"0.1.3","version":"9.0.0"}')
|
||||||
|
|
||||||
|
def test_package_inspection_is_bounded_and_clears_environment(self):
|
||||||
|
with self.assertRaisesRegex(publisher.InvalidRelease, "output limit"):
|
||||||
|
publisher.bounded_command_output([sys.executable, "-c", "print('x' * 8192)"], limit=128)
|
||||||
|
with self.assertRaisesRegex(publisher.InvalidRelease, "timed out"):
|
||||||
|
publisher.bounded_command_output([sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.1)
|
||||||
|
os.environ["SHACRAFT_INSPECTION_SECRET_TEST"] = "must-not-be-inherited"
|
||||||
|
try:
|
||||||
|
output = publisher.bounded_command_output([
|
||||||
|
sys.executable, "-c", "import os; print(os.getenv('SHACRAFT_INSPECTION_SECRET_TEST', 'clean'))",
|
||||||
|
])
|
||||||
|
finally:
|
||||||
|
del os.environ["SHACRAFT_INSPECTION_SECRET_TEST"]
|
||||||
|
self.assertEqual(output, b"clean\n")
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which(MINISIGN), "minisign CLI required for signature integration tests")
|
||||||
|
class SignatureTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = tempfile.TemporaryDirectory(prefix="shacraft-update-test-")
|
||||||
|
self.addCleanup(self.temporary.cleanup)
|
||||||
|
self.root = Path(self.temporary.name)
|
||||||
|
self.key = self.root / "fixture.key"
|
||||||
|
public = self.root / "fixture.pub"
|
||||||
|
subprocess.run(
|
||||||
|
[MINISIGN, "-G", "-W", "-p", str(public), "-s", str(self.key)],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
|
||||||
|
)
|
||||||
|
self.public_key = base64.b64encode(public.read_bytes()).decode("ascii")
|
||||||
|
self.downloads = self.root / "downloads"
|
||||||
|
release = self.downloads / "0.1.3"
|
||||||
|
release.mkdir(parents=True)
|
||||||
|
self.artifact = release / "fixture.AppImage"
|
||||||
|
self.artifact.write_bytes(appimage_fixture())
|
||||||
|
self.payload = {
|
||||||
|
"version": "0.1.3", "notes": "Проверка обновления", "pub_date": "2026-09-10T00:00:00Z",
|
||||||
|
"platforms": {"linux-x86_64": {
|
||||||
|
"url": publisher.ORIGIN + "0.1.3/fixture.AppImage",
|
||||||
|
"signature": self.sign(self.artifact),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
self.payload_path = self.root / "payload.json"
|
||||||
|
self.output = self.root / "stable.json"
|
||||||
|
|
||||||
|
def sign(self, path):
|
||||||
|
signature_path = path.with_name(path.name + ".minisig")
|
||||||
|
subprocess.run(
|
||||||
|
[MINISIGN, "-S", "-s", str(self.key), "-m", str(path), "-x", str(signature_path), "-q"],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True,
|
||||||
|
)
|
||||||
|
signature = base64.b64encode(signature_path.read_bytes()).decode("ascii")
|
||||||
|
path.with_name(path.name + ".sig").write_text(signature, encoding="ascii")
|
||||||
|
return signature
|
||||||
|
|
||||||
|
def publish(self, payload=None, dry_run=False):
|
||||||
|
self.payload_path.write_bytes(publisher.canonical(payload or self.payload))
|
||||||
|
self.sign(self.payload_path)
|
||||||
|
args = argparse.Namespace(
|
||||||
|
payload=self.payload_path, signature=self.payload_path.with_name("payload.json.sig"),
|
||||||
|
output=self.output, downloads_root=self.downloads, minisign=MINISIGN, dry_run=dry_run,
|
||||||
|
)
|
||||||
|
return publisher.publish(args, self.public_key)
|
||||||
|
|
||||||
|
def test_valid_signed_feed_binds_metadata_and_artifact(self):
|
||||||
|
self.publish()
|
||||||
|
envelope = publisher.strict_json(self.output.read_bytes())
|
||||||
|
payload_bytes = publisher.decode_tauri(envelope["signedPayload"])
|
||||||
|
self.assertEqual(publisher.strict_json(payload_bytes), self.payload)
|
||||||
|
self.assertEqual({key: envelope[key] for key in publisher.FIELDS}, self.payload)
|
||||||
|
self.assertIn("metadataSignature", envelope)
|
||||||
|
self.assertEqual(self.output.stat().st_mode & 0o777, 0o644)
|
||||||
|
|
||||||
|
def test_tampered_artifact_is_rejected_before_publication(self):
|
||||||
|
self.artifact.write_bytes(b"replaced executable")
|
||||||
|
with self.assertRaisesRegex(publisher.InvalidRelease, "signature verification failed"):
|
||||||
|
self.publish()
|
||||||
|
self.assertFalse(self.output.exists())
|
||||||
|
|
||||||
|
def test_tampered_metadata_signature_is_rejected(self):
|
||||||
|
self.payload_path.write_bytes(publisher.canonical(self.payload))
|
||||||
|
signature = self.sign(self.payload_path)
|
||||||
|
self.payload["notes"] = "Changed after signing"
|
||||||
|
self.payload_path.write_bytes(publisher.canonical(self.payload))
|
||||||
|
with self.assertRaisesRegex(publisher.InvalidRelease, "signature verification failed"):
|
||||||
|
publisher.verify_signature(self.payload_path, signature, self.public_key, MINISIGN)
|
||||||
|
|
||||||
|
def test_same_version_or_downgrade_keeps_original_feed(self):
|
||||||
|
self.publish()
|
||||||
|
original = self.output.read_bytes()
|
||||||
|
for version in ("0.1.3", "0.1.2"):
|
||||||
|
payload = copy.deepcopy(self.payload)
|
||||||
|
payload["version"] = version
|
||||||
|
with self.subTest(version=version), self.assertRaisesRegex(
|
||||||
|
publisher.InvalidRelease, "strictly increase"
|
||||||
|
):
|
||||||
|
self.publish(payload)
|
||||||
|
self.assertEqual(self.output.read_bytes(), original)
|
||||||
|
|
||||||
|
def test_foreign_url_cannot_be_signed_into_feed(self):
|
||||||
|
self.payload["platforms"]["linux-x86_64"]["url"] = "https://example.com/test.AppImage"
|
||||||
|
with self.assertRaisesRegex(publisher.InvalidRelease, "fixed ShaCraft release URL"):
|
||||||
|
self.publish()
|
||||||
|
self.assertFalse(self.output.exists())
|
||||||
|
|
||||||
|
def test_previous_version_must_also_be_authenticated(self):
|
||||||
|
self.publish()
|
||||||
|
envelope = publisher.strict_json(self.output.read_bytes())
|
||||||
|
envelope["version"] = "99.0.0"
|
||||||
|
self.output.write_bytes(publisher.canonical(envelope))
|
||||||
|
with self.assertRaisesRegex(publisher.InvalidRelease, "differ from signed metadata"):
|
||||||
|
self.publish()
|
||||||
|
|
||||||
|
def test_missing_signature_or_symlink_is_rejected(self):
|
||||||
|
original = self.artifact.read_bytes()
|
||||||
|
target = self.root / "outside.AppImage"
|
||||||
|
target.write_bytes(original)
|
||||||
|
self.artifact.unlink()
|
||||||
|
self.artifact.symlink_to(target)
|
||||||
|
with self.assertRaisesRegex(publisher.InvalidRelease, "regular file"):
|
||||||
|
self.publish()
|
||||||
|
self.artifact.unlink()
|
||||||
|
self.artifact.write_bytes(original)
|
||||||
|
self.payload["platforms"]["linux-x86_64"].pop("signature")
|
||||||
|
with self.assertRaisesRegex(publisher.InvalidRelease, "exactly url and signature"):
|
||||||
|
self.publish()
|
||||||
|
|
||||||
|
def test_dry_run_verifies_without_creating_feed(self):
|
||||||
|
self.publish(dry_run=True)
|
||||||
|
self.assertFalse(self.output.exists())
|
||||||
|
|
||||||
|
def make_deb(self, package="sha-craft-launcher", version=None, architecture="amd64"):
|
||||||
|
if not Path(publisher.DPKG_DEB).is_file():
|
||||||
|
self.skipTest("dpkg-deb required for real deb validation")
|
||||||
|
version = version or self.payload["version"]
|
||||||
|
tree = self.root / "deb-tree"
|
||||||
|
control = tree / "DEBIAN"
|
||||||
|
control.mkdir(parents=True, exist_ok=True)
|
||||||
|
(control / "control").write_text(
|
||||||
|
f"Package: {package}\nVersion: {version}\nArchitecture: {architecture}\n"
|
||||||
|
"Maintainer: Test <test@example.invalid>\nDescription: isolated updater fixture\n",
|
||||||
|
encoding="ascii",
|
||||||
|
)
|
||||||
|
# Inspection must not execute a package script, even for an authenticated package.
|
||||||
|
script = control / "preinst"
|
||||||
|
script.write_text(f"#!/bin/sh\ntouch '{self.root / 'script-executed'}'\n", encoding="ascii")
|
||||||
|
script.chmod(0o755)
|
||||||
|
deb = self.artifact.with_name("fixture.deb")
|
||||||
|
subprocess.run(
|
||||||
|
[publisher.DPKG_DEB, "--build", "--root-owner-group", str(tree), str(deb)],
|
||||||
|
env=publisher.PACKAGE_TOOL_ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
timeout=10, check=True,
|
||||||
|
)
|
||||||
|
self.payload["platforms"]["linux-x86_64-appimage"] = copy.deepcopy(
|
||||||
|
self.payload["platforms"]["linux-x86_64"]
|
||||||
|
)
|
||||||
|
self.payload["platforms"]["linux-x86_64-deb"] = {
|
||||||
|
"url": publisher.ORIGIN + self.payload["version"] + "/fixture.deb", "signature": self.sign(deb),
|
||||||
|
}
|
||||||
|
return deb
|
||||||
|
|
||||||
|
def test_format_aware_release_preserves_legacy_appimage_and_verifies_real_deb(self):
|
||||||
|
self.make_deb()
|
||||||
|
notes = self.root / "notes.txt"
|
||||||
|
notes.write_text(self.payload["notes"], encoding="utf-8")
|
||||||
|
args = argparse.Namespace(
|
||||||
|
version="0.1.3", artifact=[
|
||||||
|
"linux-x86_64=fixture.AppImage", "linux-x86_64-appimage=fixture.AppImage",
|
||||||
|
"linux-x86_64-deb=fixture.deb",
|
||||||
|
], downloads_root=self.downloads, notes_file=notes, payload=self.payload_path,
|
||||||
|
pub_date=self.payload["pub_date"], minisign=MINISIGN,
|
||||||
|
)
|
||||||
|
self.assertEqual(publisher.prepare(args, self.public_key), self.payload)
|
||||||
|
self.publish()
|
||||||
|
feed = publisher.verified_previous(self.output.read_bytes(), self.public_key, MINISIGN)
|
||||||
|
self.assertEqual(feed["platforms"]["linux-x86_64"], feed["platforms"]["linux-x86_64-appimage"])
|
||||||
|
self.assertEqual(set(feed["platforms"]), {"linux-x86_64", "linux-x86_64-appimage", "linux-x86_64-deb"})
|
||||||
|
self.assertFalse((self.root / "script-executed").exists())
|
||||||
|
|
||||||
|
def test_authenticated_legacy_feed_advances_to_format_aware_release(self):
|
||||||
|
self.publish()
|
||||||
|
old_release = self.artifact.parent
|
||||||
|
old_bytes = self.artifact.read_bytes()
|
||||||
|
new_release = self.downloads / "0.1.4"
|
||||||
|
shutil.copytree(old_release, new_release)
|
||||||
|
self.artifact = new_release / self.artifact.name
|
||||||
|
self.payload["version"] = "0.1.4"
|
||||||
|
self.payload["platforms"]["linux-x86_64"]["url"] = publisher.ORIGIN + "0.1.4/fixture.AppImage"
|
||||||
|
self.make_deb()
|
||||||
|
self.publish()
|
||||||
|
feed = publisher.verified_previous(self.output.read_bytes(), self.public_key, MINISIGN)
|
||||||
|
self.assertEqual(feed["version"], "0.1.4")
|
||||||
|
self.assertEqual(len(feed["platforms"]), 3)
|
||||||
|
self.assertEqual((old_release / self.artifact.name).read_bytes(), old_bytes)
|
||||||
|
|
||||||
|
def test_linux_format_release_cannot_drop_or_repoint_legacy_entry(self):
|
||||||
|
self.make_deb()
|
||||||
|
for key in ("linux-x86_64", "linux-x86_64-appimage"):
|
||||||
|
payload = copy.deepcopy(self.payload)
|
||||||
|
del payload["platforms"][key]
|
||||||
|
with self.subTest(key=key), self.assertRaisesRegex(publisher.InvalidRelease, "identical legacy"):
|
||||||
|
self.publish(payload)
|
||||||
|
payload = copy.deepcopy(self.payload)
|
||||||
|
payload["platforms"]["linux-x86_64-appimage"]["url"] = publisher.ORIGIN + "0.1.3/other.AppImage"
|
||||||
|
with self.assertRaisesRegex(publisher.InvalidRelease, "identical legacy"):
|
||||||
|
self.publish(payload)
|
||||||
|
self.assertFalse(self.output.exists())
|
||||||
|
|
||||||
|
def test_deb_identity_must_match_application_signed_version_and_architecture(self):
|
||||||
|
for changes in ({"package": "another-launcher"}, {"version": "9.0.0"}, {"architecture": "arm64"}):
|
||||||
|
with self.subTest(changes=changes):
|
||||||
|
self.make_deb(**changes)
|
||||||
|
with self.assertRaisesRegex(publisher.InvalidRelease, "deb identity"):
|
||||||
|
self.publish()
|
||||||
|
self.assertFalse(self.output.exists())
|
||||||
|
|
||||||
|
def test_signed_invalid_deb_is_rejected_without_running_package_scripts(self):
|
||||||
|
deb = self.make_deb()
|
||||||
|
deb.write_bytes(b"not a Debian archive")
|
||||||
|
self.payload["platforms"]["linux-x86_64-deb"]["signature"] = self.sign(deb)
|
||||||
|
with self.assertRaisesRegex(publisher.InvalidRelease, "package inspection failed"):
|
||||||
|
self.publish()
|
||||||
|
self.assertFalse((self.root / "script-executed").exists())
|
||||||
|
self.assertFalse(self.output.exists())
|
||||||
|
|
||||||
|
def test_signed_wrong_appimage_format_is_rejected(self):
|
||||||
|
for changed_slice, replacement in ((slice(8, 11), b"AI\x01"), (slice(18, 20), b"\xb7\x00")):
|
||||||
|
malformed = bytearray(appimage_fixture())
|
||||||
|
malformed[changed_slice] = replacement
|
||||||
|
self.artifact.write_bytes(malformed)
|
||||||
|
self.payload["platforms"]["linux-x86_64"]["signature"] = self.sign(self.artifact)
|
||||||
|
with self.subTest(replacement=replacement), self.assertRaisesRegex(publisher.InvalidRelease, "type-2 x86_64"):
|
||||||
|
self.publish()
|
||||||
|
self.assertFalse(self.output.exists())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Generated
+30
-101
@@ -2093,15 +2093,6 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ntapi"
|
|
||||||
version = "0.4.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
|
||||||
dependencies = [
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -2267,16 +2258,6 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "objc2-io-kit"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"objc2-core-foundation",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-io-surface"
|
name = "objc2-io-surface"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -3380,11 +3361,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shacraft-launcher"
|
name = "shacraft-launcher"
|
||||||
version = "0.2.0"
|
version = "0.1.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"flate2",
|
"flate2",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"libc",
|
||||||
"md-5",
|
"md-5",
|
||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
@@ -3394,14 +3377,14 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha1",
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sysinfo",
|
|
||||||
"tar",
|
"tar",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"time",
|
"tempfile",
|
||||||
"url",
|
"url",
|
||||||
"zip",
|
"zeroize",
|
||||||
|
"zip 2.4.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3632,20 +3615,6 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sysinfo"
|
|
||||||
version = "0.39.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"memchr",
|
|
||||||
"ntapi",
|
|
||||||
"objc2-core-foundation",
|
|
||||||
"objc2-io-kit",
|
|
||||||
"windows 0.62.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -3693,7 +3662,7 @@ dependencies = [
|
|||||||
"tao-macros",
|
"tao-macros",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"url",
|
"url",
|
||||||
"windows 0.61.3",
|
"windows",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
@@ -3775,7 +3744,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"window-vibrancy",
|
"window-vibrancy",
|
||||||
"windows 0.61.3",
|
"windows",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3859,8 +3828,6 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-updater"
|
name = "tauri-plugin-updater"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"dirs",
|
"dirs",
|
||||||
@@ -3886,6 +3853,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
|
"zip 4.6.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3910,7 +3878,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows 0.61.3",
|
"windows",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3935,7 +3903,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows 0.61.3",
|
"windows",
|
||||||
"wry",
|
"wry",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4704,7 +4672,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"webview2-com-macros",
|
"webview2-com-macros",
|
||||||
"webview2-com-sys",
|
"webview2-com-sys",
|
||||||
"windows 0.61.3",
|
"windows",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
@@ -4728,7 +4696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"windows 0.61.3",
|
"windows",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4784,23 +4752,11 @@ version = "0.61.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-collections 0.2.0",
|
"windows-collections",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-future 0.2.1",
|
"windows-future",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-numerics 0.2.0",
|
"windows-numerics",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows"
|
|
||||||
version = "0.62.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
|
||||||
dependencies = [
|
|
||||||
"windows-collections 0.3.2",
|
|
||||||
"windows-core 0.62.2",
|
|
||||||
"windows-future 0.3.2",
|
|
||||||
"windows-numerics 0.3.1",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4812,15 +4768,6 @@ dependencies = [
|
|||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-collections"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
|
||||||
dependencies = [
|
|
||||||
"windows-core 0.62.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
@@ -4855,18 +4802,7 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-threading 0.1.0",
|
"windows-threading",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-future"
|
|
||||||
version = "0.3.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
|
||||||
dependencies = [
|
|
||||||
"windows-core 0.62.2",
|
|
||||||
"windows-link 0.2.1",
|
|
||||||
"windows-threading 0.2.1",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4913,16 +4849,6 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-numerics"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
|
||||||
dependencies = [
|
|
||||||
"windows-core 0.62.2",
|
|
||||||
"windows-link 0.2.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -5061,15 +4987,6 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-threading"
|
|
||||||
version = "0.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
|
||||||
dependencies = [
|
|
||||||
"windows-link 0.2.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-version"
|
name = "windows-version"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -5301,7 +5218,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows 0.61.3",
|
"windows",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
@@ -5438,6 +5355,18 @@ dependencies = [
|
|||||||
"zopfli",
|
"zopfli",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||||
|
dependencies = [
|
||||||
|
"arbitrary",
|
||||||
|
"crc32fast",
|
||||||
|
"indexmap 2.14.2",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.7"
|
version = "0.6.7"
|
||||||
|
|||||||
+12
-8
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "shacraft-launcher"
|
name = "shacraft-launcher"
|
||||||
version = "0.2.0"
|
version = "0.1.7"
|
||||||
description = "ShaCraft Minecraft launcher"
|
description = "ShaCraft Minecraft launcher"
|
||||||
authors = ["ShaCraft"]
|
authors = ["ShaCraft"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -20,17 +20,21 @@ sha1 = "0.10"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
md-5 = "0.10"
|
md-5 = "0.10"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
ed25519-dalek = "2"
|
ed25519-dalek = { version = "2", features = ["pkcs8"] }
|
||||||
|
getrandom = "0.3"
|
||||||
|
zeroize = "1"
|
||||||
|
libc = "0.2"
|
||||||
|
tempfile = "3"
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-updater = { version = "=2.11.0", path = "../vendor/tauri-plugin-updater", default-features = false, features = ["rustls-tls", "zip"] }
|
||||||
|
reqwest-updater = { package = "reqwest", version = "0.13", default-features = false }
|
||||||
|
minisign-verify = "0.2"
|
||||||
|
semver = "1"
|
||||||
url = "2"
|
url = "2"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"] }
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||||
sysinfo = { version = "0.39.6", default-features = false, features = ["system"] }
|
|
||||||
|
|
||||||
tauri-plugin-updater = { version = "=2.11.0", default-features = false, features = ["rustls-tls"] }
|
[dev-dependencies]
|
||||||
minisign-verify = "=0.2.5"
|
tauri = { version = "2", features = ["test"] }
|
||||||
semver = "1"
|
|
||||||
time = { version = "0.3", features = ["parsing", "formatting"] }
|
|
||||||
reqwest-updater = { package = "reqwest", version = "0.13", default-features = false, features = ["rustls-no-provider"] }
|
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
println!("cargo:rerun-if-env-changed=SHACRAFT_UPDATER_PUBLIC_KEY");
|
|
||||||
println!("cargo:rerun-if-env-changed=SHACRAFT_UPDATER_TEST_BUILD");
|
|
||||||
println!("cargo:rerun-if-changed=updater-public-key.txt");
|
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
//! Ephemeral proof of possession for one Aeronautics connection.
|
||||||
|
//!
|
||||||
|
//! Neither this private key nor its ticket crosses IPC, enters launch arguments,
|
||||||
|
//! or is persisted. Only the new Java child's environment receives them. A new
|
||||||
|
//! launch obtains a new key and ticket; account session credentials stay native.
|
||||||
|
|
||||||
|
use crate::session::PlayerIdentity;
|
||||||
|
use base64::{
|
||||||
|
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||||
|
Engine,
|
||||||
|
};
|
||||||
|
use ed25519_dalek::{
|
||||||
|
pkcs8::{EncodePrivateKey, KeypairBytes},
|
||||||
|
SigningKey,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::process::Command;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
pub(crate) const TICKET_ENV: &str = "SHACRAFT_ADMISSION_TICKET";
|
||||||
|
pub(crate) const PRIVATE_KEY_ENV: &str = "SHACRAFT_ADMISSION_PRIVATE_KEY";
|
||||||
|
#[cfg(test)]
|
||||||
|
const SERVER_ID: &str = "aoc";
|
||||||
|
|
||||||
|
pub(crate) fn server_for_profile(profile: &str) -> Result<&'static str, &'static str> {
|
||||||
|
match profile {
|
||||||
|
"aeronautics" => Ok("aoc"),
|
||||||
|
"minigames" => Ok("minigames"),
|
||||||
|
_ => Err("Unknown ShaCraft profile"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const MAX_LIFETIME_SECONDS: u64 = 600;
|
||||||
|
|
||||||
|
// Deliberately no Debug, Clone or Serialize for secret-bearing values.
|
||||||
|
pub(crate) struct AdmissionKey {
|
||||||
|
server_id: &'static str,
|
||||||
|
public_key: String,
|
||||||
|
private_key: Zeroizing<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(crate) struct TicketRequest<'a> {
|
||||||
|
server_id: &'static str,
|
||||||
|
public_key: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(crate) struct TicketResponse {
|
||||||
|
ticket_id: String,
|
||||||
|
mc_username: String,
|
||||||
|
server_id: String,
|
||||||
|
expires_in_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct Admission {
|
||||||
|
server_id: &'static str,
|
||||||
|
ticket_id: Zeroizing<String>,
|
||||||
|
private_key: Zeroizing<String>,
|
||||||
|
identity: PlayerIdentity,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AdmissionKey {
|
||||||
|
pub(crate) fn generate(server_id: &'static str) -> Result<Self, &'static str> {
|
||||||
|
if !matches!(server_id, "aoc" | "minigames") { return Err("Unknown ShaCraft server"); }
|
||||||
|
let mut seed = Zeroizing::new([0_u8; 32]);
|
||||||
|
getrandom::fill(seed.as_mut())
|
||||||
|
.map_err(|_| "Не удалось создать защищённый ключ входа. Повторите запуск лаунчера.")?;
|
||||||
|
let signing_key = SigningKey::from_bytes(&seed);
|
||||||
|
let public_key = STANDARD.encode(signing_key.verifying_key().to_bytes());
|
||||||
|
// RFC 8410 PKCS#8 v1 (PrivateKeyInfo) without the optional public key.
|
||||||
|
// This is accepted by Java 21's Ed25519 KeyFactory/PKCS8EncodedKeySpec.
|
||||||
|
let key_bytes = KeypairBytes {
|
||||||
|
secret_key: signing_key.to_bytes(),
|
||||||
|
public_key: None,
|
||||||
|
};
|
||||||
|
let encoded = key_bytes
|
||||||
|
.to_pkcs8_der()
|
||||||
|
.map_err(|_| "Не удалось подготовить защищённый ключ входа.")?;
|
||||||
|
Ok(Self {
|
||||||
|
server_id,
|
||||||
|
public_key,
|
||||||
|
private_key: Zeroizing::new(STANDARD.encode(encoded.as_bytes())),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn request(&self) -> TicketRequest<'_> {
|
||||||
|
TicketRequest {
|
||||||
|
server_id: self.server_id,
|
||||||
|
public_key: &self.public_key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn bind(self, response: TicketResponse) -> Result<Admission, &'static str> {
|
||||||
|
let ticket_id = Zeroizing::new(response.ticket_id);
|
||||||
|
let valid_ticket = ticket_id.len() == 43
|
||||||
|
&& URL_SAFE_NO_PAD
|
||||||
|
.decode(ticket_id.as_bytes())
|
||||||
|
.is_ok_and(|bytes| {
|
||||||
|
bytes.len() == 32 && URL_SAFE_NO_PAD.encode(bytes) == *ticket_id
|
||||||
|
});
|
||||||
|
let valid_nickname = (3..=16).contains(&response.mc_username.len())
|
||||||
|
&& response
|
||||||
|
.mc_username
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_');
|
||||||
|
if !valid_ticket
|
||||||
|
|| !valid_nickname
|
||||||
|
|| response.server_id != self.server_id
|
||||||
|
|| !(1..=MAX_LIFETIME_SECONDS).contains(&response.expires_in_seconds)
|
||||||
|
{
|
||||||
|
return Err("Сервер вернул некорректное разрешение на вход. Повторите попытку позже.");
|
||||||
|
}
|
||||||
|
Ok(Admission {
|
||||||
|
server_id: self.server_id,
|
||||||
|
ticket_id,
|
||||||
|
private_key: self.private_key,
|
||||||
|
identity: PlayerIdentity::Offline {
|
||||||
|
name: response.mc_username,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Admission {
|
||||||
|
pub(crate) fn server_id(&self) -> &str { self.server_id }
|
||||||
|
pub(crate) fn identity(&self) -> &PlayerIdentity {
|
||||||
|
&self.identity
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn configure_child(&self, command: &mut Command) {
|
||||||
|
command.env(TICKET_ENV, self.ticket_id.as_str());
|
||||||
|
command.env(PRIVATE_KEY_ENV, self.private_key.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ed25519_dalek::{pkcs8::DecodePrivateKey, Signer, Verifier};
|
||||||
|
|
||||||
|
fn response() -> TicketResponse {
|
||||||
|
TicketResponse {
|
||||||
|
ticket_id: URL_SAFE_NO_PAD.encode([37_u8; 32]),
|
||||||
|
mc_username: "Canonical_Name".into(),
|
||||||
|
server_id: SERVER_ID.into(),
|
||||||
|
expires_in_seconds: 600,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_tickets_are_server_bound_with_shared_canonical_identity() {
|
||||||
|
assert_eq!(server_for_profile("aeronautics").unwrap(), "aoc");
|
||||||
|
assert_eq!(server_for_profile("minigames").unwrap(), "minigames");
|
||||||
|
assert!(server_for_profile("../../other").is_err());
|
||||||
|
assert!(AdmissionKey::generate("other").is_err());
|
||||||
|
assert!(AdmissionKey::generate("minigames").unwrap().bind(response()).is_err());
|
||||||
|
let key=AdmissionKey::generate("minigames").unwrap();
|
||||||
|
assert_eq!(serde_json::to_value(key.request()).unwrap()["server_id"], "minigames");
|
||||||
|
let mut payload=response(); payload.server_id="minigames".into();
|
||||||
|
let admitted=key.bind(payload).unwrap();
|
||||||
|
assert_eq!(admitted.server_id(), "minigames");
|
||||||
|
assert_eq!(admitted.identity().name(), "Canonical_Name");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generates_distinct_keys_and_only_sends_the_public_key() {
|
||||||
|
let key = AdmissionKey::generate("aoc").unwrap();
|
||||||
|
let other = AdmissionKey::generate("aoc").unwrap();
|
||||||
|
assert_ne!(key.public_key, other.public_key);
|
||||||
|
let payload = serde_json::to_value(key.request()).unwrap();
|
||||||
|
assert_eq!(payload.as_object().unwrap().len(), 2);
|
||||||
|
assert_eq!(payload["server_id"], SERVER_ID);
|
||||||
|
assert_eq!(payload["public_key"], key.public_key);
|
||||||
|
assert!(!payload.to_string().contains(key.private_key.as_str()));
|
||||||
|
let der = Zeroizing::new(STANDARD.decode(key.private_key.as_bytes()).unwrap());
|
||||||
|
let restored = SigningKey::from_pkcs8_der(&der).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
STANDARD.encode(restored.verifying_key().to_bytes()),
|
||||||
|
key.public_key
|
||||||
|
);
|
||||||
|
let message = b"shacraft-admission-v1:challenge-fixture";
|
||||||
|
restored
|
||||||
|
.verifying_key()
|
||||||
|
.verify(message, &restored.sign(message))
|
||||||
|
.unwrap();
|
||||||
|
// Java's standard Ed25519 encoding is the 48-byte private-key-only form.
|
||||||
|
assert_eq!(der.len(), 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_untrusted_identity_ticket_server_and_expiry() {
|
||||||
|
let mutations: Vec<Box<dyn Fn(&mut TicketResponse)>> = vec![
|
||||||
|
Box::new(|r| r.ticket_id = "../unsafe".into()),
|
||||||
|
Box::new(|r| r.ticket_id = "A".repeat(42) + "!"),
|
||||||
|
Box::new(|r| r.ticket_id = "A".repeat(42) + "B"),
|
||||||
|
Box::new(|r| r.mc_username = "../../outside".into()),
|
||||||
|
Box::new(|r| r.mc_username = "ab".into()),
|
||||||
|
Box::new(|r| r.mc_username = "a".repeat(17)),
|
||||||
|
Box::new(|r| r.server_id = "other".into()),
|
||||||
|
Box::new(|r| r.expires_in_seconds = 0),
|
||||||
|
Box::new(|r| r.expires_in_seconds = 601),
|
||||||
|
];
|
||||||
|
for mutate in mutations {
|
||||||
|
let mut payload = response();
|
||||||
|
mutate(&mut payload);
|
||||||
|
assert!(AdmissionKey::generate("aoc").unwrap().bind(payload).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secrets_only_enter_the_child_environment_and_identity_comes_from_ticket() {
|
||||||
|
let original_ticket = std::env::var_os(TICKET_ENV);
|
||||||
|
let original_key = std::env::var_os(PRIVATE_KEY_ENV);
|
||||||
|
let admission = AdmissionKey::generate("aoc").unwrap().bind(response()).unwrap();
|
||||||
|
let mut command = Command::new("java");
|
||||||
|
command
|
||||||
|
.arg("-Xmx6144M")
|
||||||
|
.arg("net.minecraft.client.main.Main");
|
||||||
|
admission.configure_child(&mut command);
|
||||||
|
assert_eq!(admission.identity().name(), "Canonical_Name");
|
||||||
|
let env: std::collections::HashMap<_, _> = command.get_envs().collect();
|
||||||
|
assert_eq!(
|
||||||
|
env.get(std::ffi::OsStr::new(TICKET_ENV)).unwrap().unwrap(),
|
||||||
|
admission.ticket_id.as_str()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
env.get(std::ffi::OsStr::new(PRIVATE_KEY_ENV))
|
||||||
|
.unwrap()
|
||||||
|
.unwrap(),
|
||||||
|
admission.private_key.as_str()
|
||||||
|
);
|
||||||
|
assert_eq!(env.len(), 2);
|
||||||
|
for argument in command.get_args() {
|
||||||
|
assert!(!argument
|
||||||
|
.to_string_lossy()
|
||||||
|
.contains(admission.ticket_id.as_str()));
|
||||||
|
assert!(!argument
|
||||||
|
.to_string_lossy()
|
||||||
|
.contains(admission.private_key.as_str()));
|
||||||
|
}
|
||||||
|
assert_eq!(std::env::var_os(TICKET_ENV), original_ticket);
|
||||||
|
assert_eq!(std::env::var_os(PRIVATE_KEY_ENV), original_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,10 +30,8 @@ pub(crate) fn start_microsoft_login(
|
|||||||
state: State<'_, LauncherOperations>,
|
state: State<'_, LauncherOperations>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let directory = data_dir(&app)?;
|
let directory = data_dir(&app)?;
|
||||||
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
|
|
||||||
let permit = state.account.acquire("Account operation")?;
|
let permit = state.account.acquire("Account operation")?;
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
let _lifecycle = lifecycle;
|
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
let login = || -> Result<msa::MinecraftProfile, String> {
|
let login = || -> Result<msa::MinecraftProfile, String> {
|
||||||
let client = msa::http_client().map_err(|error| error.to_string())?;
|
let client = msa::http_client().map_err(|error| error.to_string())?;
|
||||||
@@ -78,10 +76,8 @@ pub(crate) async fn get_account(
|
|||||||
state: State<'_, LauncherOperations>,
|
state: State<'_, LauncherOperations>,
|
||||||
) -> Result<Option<msa::MinecraftProfile>, String> {
|
) -> Result<Option<msa::MinecraftProfile>, String> {
|
||||||
let data_dir = data_dir(&app)?;
|
let data_dir = data_dir(&app)?;
|
||||||
let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?;
|
|
||||||
let permit = state.account.acquire("Account operation")?;
|
let permit = state.account.acquire("Account operation")?;
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
let _lifecycle = lifecycle;
|
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
let Some(refresh_token) = msa::load_refresh_token(&data_dir) else {
|
let Some(refresh_token) = msa::load_refresh_token(&data_dir) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -106,10 +102,8 @@ pub(crate) async fn logout(
|
|||||||
state: State<'_, LauncherOperations>,
|
state: State<'_, LauncherOperations>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let data_dir = data_dir(&app)?;
|
let data_dir = data_dir(&app)?;
|
||||||
let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?;
|
|
||||||
let permit = state.account.acquire("Account operation")?;
|
let permit = state.account.acquire("Account operation")?;
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
let _lifecycle = lifecycle;
|
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
msa::clear_account(&data_dir)
|
msa::clear_account(&data_dir)
|
||||||
})
|
})
|
||||||
|
|||||||
+182
-258
@@ -1,7 +1,7 @@
|
|||||||
use super::{data_dir, profiles::ProfileMetadata, shacraft::resolve_identity};
|
use super::data_dir;
|
||||||
use crate::{
|
use crate::{
|
||||||
installation_lock::InstallationLock, java, launch, manifest, mojang, neoforge,
|
fabric, java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
|
||||||
operations::LauncherOperations, profile, remote, runtime, session, settings, shacraft_account,
|
settings, shacraft_account,
|
||||||
};
|
};
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -15,158 +15,135 @@ pub(crate) struct InstallProgress {
|
|||||||
current_bytes: u64,
|
current_bytes: u64,
|
||||||
total_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(
|
fn resolve_merged_version(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
manifest: &manifest::Manifest,
|
manifest: &manifest::Manifest,
|
||||||
java: &Path,
|
java_executable: &Path,
|
||||||
game_dir: &Path,
|
game_dir: &Path,
|
||||||
cache_dir: &Path,
|
cache_dir: &Path,
|
||||||
on_progress: &mojang::ProgressCallback,
|
on_progress: &mojang::ProgressCallback,
|
||||||
) -> Result<mojang::MergedVersion, String> {
|
) -> Result<mojang::MergedVersion, String> {
|
||||||
let catalog = mojang::fetch_version_manifest(client).map_err(|e| e.to_string())?;
|
let mojang_manifest =
|
||||||
let entry = mojang::find_version(&catalog, &manifest.minecraft.version)
|
mojang::fetch_version_manifest(client).map_err(|error| error.to_string())?;
|
||||||
.ok_or_else(|| format!("Mojang does not list {}", manifest.minecraft.version))?;
|
let vanilla_entry = mojang::find_version(&mojang_manifest, &manifest.minecraft.version)
|
||||||
let vanilla = mojang::fetch_version_json(client, entry).map_err(|e| e.to_string())?;
|
.ok_or_else(|| {
|
||||||
// The installer may reuse an existing vanilla JAR without verifying it.
|
format!(
|
||||||
// Establish provider integrity BEFORE any NeoForge processor uses that input.
|
"Mojang does not list Minecraft version {}",
|
||||||
mojang::ensure_client_jar(
|
manifest.minecraft.version
|
||||||
client,
|
)
|
||||||
game_dir,
|
})?;
|
||||||
&vanilla.id,
|
let vanilla =
|
||||||
&vanilla
|
mojang::fetch_version_json(client, vanilla_entry).map_err(|error| error.to_string())?;
|
||||||
.downloads
|
|
||||||
.as_ref()
|
|
||||||
.ok_or("Vanilla client download metadata is missing")?
|
|
||||||
.client,
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
if manifest.minecraft.loader.kind == "neoforge" {
|
if manifest.minecraft.loader.kind == "neoforge" {
|
||||||
let installer_client = neoforge::http_client().map_err(|e| e.to_string())?;
|
let installer_client = neoforge::http_client().map_err(|error| error.to_string())?;
|
||||||
let loader = neoforge::ensure_client_installed(
|
let neoforge_version = neoforge::ensure_client_installed(
|
||||||
&installer_client,
|
&installer_client,
|
||||||
java,
|
java_executable,
|
||||||
game_dir,
|
game_dir,
|
||||||
cache_dir,
|
cache_dir,
|
||||||
&manifest.minecraft.loader.version,
|
&manifest.minecraft.loader.version,
|
||||||
&vanilla,
|
|
||||||
on_progress,
|
on_progress,
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
mojang::merge_versions(&vanilla, Some(&loader)).map_err(|e| e.to_string())
|
mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string())
|
||||||
|
} else if manifest.minecraft.loader.kind == "fabric" {
|
||||||
|
let child = fabric::fetch_profile(&manifest.minecraft.version, &manifest.minecraft.loader.version)?;
|
||||||
|
mojang::merge_versions(&vanilla, Some(&child)).map_err(|error| error.to_string())
|
||||||
} else {
|
} else {
|
||||||
mojang::merge_versions(&vanilla, None).map_err(|e| e.to_string())
|
mojang::merge_versions(&vanilla, None).map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Internal stages receive the same verified snapshot; none can refetch it.
|
/// Downloads and installs everything needed to run `profile_id`: the
|
||||||
fn prepare(
|
/// exact Minecraft/loader version the ShaCraft-signed manifest specifies,
|
||||||
directory: &Path,
|
/// a Java runtime if none is already usable, and game assets. Emits
|
||||||
snapshot: &remote::VerifiedSnapshot,
|
/// `game-install-progress` throughout with real progress for every stage:
|
||||||
progress: &impl Fn(&'static str) -> mojang::ProgressCallback,
|
/// download bytes for Java, installer-confirmed library/processor counts
|
||||||
) -> Result<
|
/// for NeoForge, and download bytes for libraries/assets.
|
||||||
(
|
|
||||||
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<shacraft_account::LinkStart>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full repair, using one snapshot and one writer lock for mods AND the game.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn ensure_game_installed(
|
pub(crate) async fn ensure_game_installed(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, LauncherOperations>,
|
state: State<'_, LauncherOperations>,
|
||||||
profile_id: String,
|
profile_id: String,
|
||||||
) -> Result<PreparationResult, String> {
|
) -> Result<(), String> {
|
||||||
let directory = data_dir(&app)?;
|
let game_dir = data_dir(&app)?.join("game");
|
||||||
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
|
let runtime_root = game_dir.join("runtime");
|
||||||
|
let cache_dir = game_dir.join("cache");
|
||||||
let permit = state.installation.acquire("Installation")?;
|
let permit = state.installation.acquire("Installation")?;
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
|
||||||
let _lifecycle = lifecycle;
|
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
let _lock = InstallationLock::acquire(&directory)?;
|
let client = mojang::http_client().map_err(|error| error.to_string())?;
|
||||||
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
|
let runtime_client = runtime::http_client().map_err(|error| error.to_string())?;
|
||||||
let (_, _, inspection) = prepare(&directory, &snapshot, &|stage| progress(&app, stage))?;
|
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
||||||
Ok(PreparationResult {
|
|
||||||
inspection,
|
let stage_progress = |stage: &'static str| -> mojang::ProgressCallback {
|
||||||
metadata: (&snapshot).into(),
|
let app = app.clone();
|
||||||
onboarding: None,
|
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(())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|error| format!("Install task failed: {error}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
@@ -176,143 +153,90 @@ pub(crate) struct GameExited {
|
|||||||
exit_code: Option<i32>,
|
exit_code: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn play(
|
/// Launches `profile_id` with the verified ShaCraft account's linked nickname.
|
||||||
app: AppHandle,
|
/// Local legacy nickname/account-mode preferences cannot override the link.
|
||||||
state: &LauncherOperations,
|
/// Spawns the game detached; watches it on a
|
||||||
profile_id: String,
|
/// background thread only to emit `game-exited` when it eventually closes.
|
||||||
onboarding_name: Option<String>,
|
|
||||||
) -> Result<PreparationResult, String> {
|
|
||||||
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]
|
#[tauri::command]
|
||||||
pub(crate) async fn launch_game(
|
pub(crate) async fn launch_game(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, LauncherOperations>,
|
state: State<'_, LauncherOperations>,
|
||||||
profile_id: String,
|
profile_id: String,
|
||||||
) -> Result<PreparationResult, String> {
|
) -> Result<(), String> {
|
||||||
play(app, &state, profile_id, None).await
|
let game_dir = data_dir(&app)?.join("game");
|
||||||
}
|
let data_dir = data_dir(&app)?;
|
||||||
#[tauri::command]
|
let permit = state.installation.acquire("Installation")?;
|
||||||
pub(crate) async fn launch_onboarding(
|
let game_permit = state.game.acquire("Игра")?;
|
||||||
app: AppHandle,
|
let account_operation = state.shacraft_account.clone();
|
||||||
state: State<'_, LauncherOperations>,
|
|
||||||
profile_id: String,
|
|
||||||
nickname: String,
|
|
||||||
) -> Result<PreparationResult, String> {
|
|
||||||
if !shacraft_account::valid_nickname(&nickname) {
|
|
||||||
return Err("Неверный игровой ник".into());
|
|
||||||
}
|
|
||||||
play(app, &state, profile_id, Some(nickname)).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
|
||||||
mod tests {
|
let _permit = permit;
|
||||||
use super::*;
|
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())?;
|
||||||
|
|
||||||
/// Real provider downloads and installer execution; never logs in or joins
|
// Everything here should already be installed by `ensure_game_installed`,
|
||||||
/// a server. Artifacts stay in a fresh temporary directory for diagnosis.
|
// so these are expected to hit their fast paths; no progress to show.
|
||||||
#[test]
|
let no_progress: mojang::ProgressCallback = Arc::new(|_, _| {});
|
||||||
#[ignore = "downloads the real pack/game and executes the official installer; needs network and Java 21"]
|
let java_install = java::ensure_java(
|
||||||
fn live_cold_install_and_corruption_repair() {
|
&runtime_client,
|
||||||
let directory = std::env::temp_dir().canonicalize().unwrap().join(format!(
|
&game_dir.join("runtime"),
|
||||||
"shacraft-cold-install-{}",
|
manifest.minecraft.java_major,
|
||||||
SystemTime::now()
|
&no_progress,
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
)
|
||||||
.unwrap()
|
.map_err(|error| error.to_string())?;
|
||||||
.as_nanos()
|
let merged = resolve_merged_version(
|
||||||
));
|
&client,
|
||||||
assert!(!directory.exists());
|
&manifest,
|
||||||
eprintln!("Isolated installation: {}", directory.display());
|
Path::new(&java_install.executable),
|
||||||
let _lock = InstallationLock::acquire(&directory).unwrap();
|
&game_dir,
|
||||||
let snapshot = remote::fetch_snapshot("aeronautics").unwrap();
|
&game_dir.join("cache"),
|
||||||
let callback = |stage| -> mojang::ProgressCallback {
|
&no_progress,
|
||||||
eprintln!("Stage: {stage}");
|
)?;
|
||||||
Arc::new(|_, _| {})
|
|
||||||
|
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"));
|
||||||
|
|
||||||
|
// Generate fresh proof only after installation. Keep the account gate
|
||||||
|
// through spawn so local logout/account switching cannot race issuance.
|
||||||
|
let _account_permit = account_operation.acquire("ShaCraft account operation")?;
|
||||||
|
let admission =
|
||||||
|
shacraft_account::issue_admission(&data_dir, crate::admission::server_for_profile(&profile_id)?).map_err(|error| error.to_string())?;
|
||||||
|
let request = launch::LaunchRequest {
|
||||||
|
java_executable: Path::new(&java_install.executable),
|
||||||
|
game_dir: &game_dir,
|
||||||
|
profile_dir: &profile_dir,
|
||||||
|
merged: &merged,
|
||||||
|
admission: &admission,
|
||||||
|
memory_mb: settings.memory_mb,
|
||||||
|
log_path: &log_path,
|
||||||
};
|
};
|
||||||
let (_, java, inspection) = prepare(&directory, &snapshot, &callback).unwrap();
|
let mut child = launch::launch(&request).map_err(|error| error.to_string())?;
|
||||||
assert!(inspection.up_to_date);
|
|
||||||
assert_eq!(java.major, snapshot.manifest.minecraft.java_major);
|
let watch_app = app.clone();
|
||||||
let game = directory.join("game");
|
let watch_profile_id = profile_id.clone();
|
||||||
let version = &snapshot.manifest.minecraft.loader.version;
|
std::thread::spawn(move || {
|
||||||
let json = neoforge::installed_version_json_path(&game, version);
|
let game_permit = game_permit;
|
||||||
let jar = game.join(format!(
|
let exit_code = child.wait().ok().and_then(|status| status.code());
|
||||||
"libraries/net/neoforged/neoforge/{version}/neoforge-{version}-client.jar"
|
drop(game_permit);
|
||||||
));
|
let _ = watch_app.emit(
|
||||||
let original_json = std::fs::read(&json).unwrap();
|
"game-exited",
|
||||||
std::fs::write(&json, b"nonempty corrupted version JSON").unwrap();
|
GameExited {
|
||||||
std::fs::write(&jar, b"nonempty corrupted patched JAR").unwrap();
|
profile_id: watch_profile_id,
|
||||||
let (_, _, repaired) = prepare(&directory, &snapshot, &callback).unwrap();
|
exit_code,
|
||||||
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!(
|
Ok(())
|
||||||
prepare(&directory, &snapshot, &callback)
|
})
|
||||||
.unwrap()
|
.await
|
||||||
.2
|
.map_err(|error| format!("Launch task failed: {error}"))?
|
||||||
.up_to_date
|
|
||||||
);
|
|
||||||
eprintln!(
|
|
||||||
"Cold install, corrupt JSON/JAR repair and healthy recheck passed: {}",
|
|
||||||
directory.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::data_dir;
|
use super::data_dir;
|
||||||
use crate::{operations::LauncherOperations, settings};
|
use crate::settings;
|
||||||
use tauri::{AppHandle, State};
|
use tauri::AppHandle;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> {
|
pub(crate) async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> {
|
||||||
@@ -14,16 +14,11 @@ pub(crate) async fn load_settings(app: AppHandle) -> Result<settings::LauncherSe
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn save_settings(
|
pub(crate) async fn save_settings(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, LauncherOperations>,
|
|
||||||
settings: settings::LauncherSettings,
|
settings: settings::LauncherSettings,
|
||||||
) -> Result<settings::LauncherSettings, String> {
|
) -> Result<settings::LauncherSettings, String> {
|
||||||
let data_dir = data_dir(&app)?;
|
let data_dir = data_dir(&app)?;
|
||||||
let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?;
|
tauri::async_runtime::spawn_blocking(move || settings::save(&data_dir, settings))
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
.await
|
||||||
let _lifecycle = lifecycle;
|
.map_err(|error| format!("Settings task failed: {error}"))?
|
||||||
settings::save(&data_dir, settings)
|
.map_err(|error| error.to_string())
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|error| format!("Settings task failed: {error}"))?
|
|
||||||
.map_err(|error| error.to_string())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,134 +1,47 @@
|
|||||||
use super::data_dir;
|
use super::data_dir;
|
||||||
use crate::{installation_lock::InstallationLock, operations::LauncherOperations, profile, remote};
|
use crate::{operations::LauncherOperations, profile, remote};
|
||||||
use serde::Serialize;
|
|
||||||
use tauri::{AppHandle, State};
|
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<ProfileMetadata, String> {
|
|
||||||
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]
|
#[tauri::command]
|
||||||
pub(crate) async fn get_server_status(profile_id: String) -> Result<remote::ServerStatus, String> {
|
pub(crate) async fn get_server_status(profile_id: String) -> Result<remote::ServerStatus, String> {
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
remote::fetch_server_status(&profile_id).map_err(|e| e.to_string())
|
remote::fetch_server_status(&profile_id).map_err(|error| error.to_string())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|error| format!("Server-status task failed: {error}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loads and validates the published ShaCraft manifest before inspecting a profile.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn inspect_remote_profile(
|
pub(crate) async fn inspect_remote_profile(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, LauncherOperations>,
|
|
||||||
profile_id: String,
|
profile_id: String,
|
||||||
) -> Result<profile::ProfileInspection, String> {
|
) -> Result<profile::ProfileInspection, String> {
|
||||||
let directory = data_dir(&app)?;
|
let data_dir = data_dir(&app)?;
|
||||||
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
|
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
let _lifecycle = lifecycle;
|
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
||||||
// Inspection must not report a partially applied journal as ready.
|
profile::inspect(&data_dir.join("profiles").join(&manifest.id), &manifest)
|
||||||
let _lock = InstallationLock::acquire(&directory)?;
|
.map_err(|error| error.to_string())
|
||||||
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
|
.await
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|error| format!("Profile inspection task failed: {error}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Downloads missing or changed ShaCraft-managed files from the fixed v2 endpoint.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn sync_remote_profile(
|
pub(crate) async fn sync_remote_profile(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, LauncherOperations>,
|
state: State<'_, LauncherOperations>,
|
||||||
profile_id: String,
|
profile_id: String,
|
||||||
) -> Result<profile::SyncResult, String> {
|
) -> Result<profile::SyncResult, String> {
|
||||||
let directory = data_dir(&app)?;
|
let data_dir = data_dir(&app)?;
|
||||||
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
|
|
||||||
let permit = state.installation.acquire("Installation")?;
|
let permit = state.installation.acquire("Installation")?;
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
let _lifecycle = lifecycle;
|
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
let _lock = InstallationLock::acquire(&directory)?;
|
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
||||||
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
|
profile::sync(&data_dir.join("profiles").join(&manifest.id), &manifest)
|
||||||
profile::sync_snapshot(
|
.map_err(|error| error.to_string())
|
||||||
&directory.join("profiles").join(&snapshot.manifest.id),
|
|
||||||
&snapshot,
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|error| format!("Profile synchronization task failed: {error}"))?
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub(crate) async fn legacy_mods(
|
|
||||||
app: AppHandle,
|
|
||||||
state: State<'_, LauncherOperations>,
|
|
||||||
profile_id: String,
|
|
||||||
) -> Result<Vec<profile::LegacyMod>, 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<profile::LegacySelection>,
|
|
||||||
) -> Result<profile::LegacyBackup, String> {
|
|
||||||
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())?
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
//! ShaCraft sessions and verified account links are the launch identity source.
|
//! ShaCraft sessions and verified account links are the launch identity source.
|
||||||
use super::data_dir;
|
use super::data_dir;
|
||||||
use crate::{
|
use crate::{operations::LauncherOperations, shacraft_account};
|
||||||
operations::{LauncherOperations, Operation},
|
|
||||||
session, shacraft_account,
|
|
||||||
};
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
@@ -13,12 +10,10 @@ async fn account_task<T: Send + 'static>(
|
|||||||
work: impl FnOnce(&Path) -> Result<T, shacraft_account::AccountError> + Send + 'static,
|
work: impl FnOnce(&Path) -> Result<T, shacraft_account::AccountError> + Send + 'static,
|
||||||
) -> Result<T, String> {
|
) -> Result<T, String> {
|
||||||
let directory = data_dir(app)?;
|
let directory = data_dir(app)?;
|
||||||
let lifecycle = crate::update_guard::begin_operation(&directory, operations)?;
|
|
||||||
let permit = operations
|
let permit = operations
|
||||||
.shacraft_account
|
.shacraft_account
|
||||||
.acquire("ShaCraft account operation")?;
|
.acquire("ShaCraft account operation")?;
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
let _lifecycle = lifecycle;
|
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
work(&directory).map_err(|error| error.to_string())
|
work(&directory).map_err(|error| error.to_string())
|
||||||
})
|
})
|
||||||
@@ -77,6 +72,18 @@ pub(crate) async fn shacraft_start_link(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) async fn shacraft_claim_nickname(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, LauncherOperations>,
|
||||||
|
nickname: String,
|
||||||
|
) -> Result<shacraft_account::Account, String> {
|
||||||
|
account_task(&app, &state, move |directory| {
|
||||||
|
shacraft_account::claim_nickname(directory, &nickname)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn shacraft_link_status(
|
pub(crate) async fn shacraft_link_status(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -88,35 +95,3 @@ pub(crate) async fn shacraft_link_status(
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Always revalidate the server session and its aoc link. Legacy local settings
|
|
||||||
/// and Microsoft tokens do not select the identity in the ShaCraft-only flow.
|
|
||||||
pub(super) fn resolve_identity(
|
|
||||||
directory: &Path,
|
|
||||||
operation: &Operation,
|
|
||||||
) -> Result<session::PlayerIdentity, String> {
|
|
||||||
let _permit = operation.acquire("ShaCraft account operation")?;
|
|
||||||
let name =
|
|
||||||
shacraft_account::aeronautics_nickname(directory).map_err(|error| error.to_string())?;
|
|
||||||
Ok(session::PlayerIdentity::Offline { name })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_shacraft_session_cannot_fall_back_to_legacy_nickname() {
|
|
||||||
let directory = std::env::temp_dir().join(format!(
|
|
||||||
"shacraft-identity-test-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos()
|
|
||||||
));
|
|
||||||
crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap();
|
|
||||||
assert!(resolve_identity(&directory, &Operation::default()).is_err());
|
|
||||||
std::fs::remove_dir_all(directory).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+183
-138
@@ -1,158 +1,203 @@
|
|||||||
//! No updater command accepts a URL, key, target, version, executable or arguments.
|
|
||||||
use super::data_dir;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
operations::LauncherOperations,
|
operations::LauncherOperations,
|
||||||
update_guard,
|
updater::{self, LauncherUpdater, Stage, UpdateProgress, UpdateStatus},
|
||||||
updater::{self, UpdateStatus, UpdaterState},
|
|
||||||
};
|
};
|
||||||
use tauri::{AppHandle, Manager, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
fn pending(app: &AppHandle, state: &UpdaterState) -> Result<Option<UpdateStatus>, String> {
|
#[tauri::command]
|
||||||
let operations = app.state::<LauncherOperations>();
|
pub(crate) fn get_launcher_update_status(
|
||||||
if let Err(reason) = operations.ensure_writable() {
|
app: AppHandle,
|
||||||
return Ok(Some(state.recovery(app, reason)));
|
updater: State<'_, LauncherUpdater>,
|
||||||
}
|
) -> Result<UpdateStatus, String> {
|
||||||
let directory = data_dir(app)?;
|
updater.status(&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<T>(
|
|
||||||
work: impl FnOnce() -> Result<T, String>,
|
|
||||||
panic_message: &str,
|
|
||||||
) -> Result<T, String> {
|
|
||||||
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::<LauncherOperations>()
|
|
||||||
.latch_recovery(reason.clone());
|
|
||||||
state.recovery(app, reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) fn updater_status(
|
pub(crate) async fn check_launcher_update(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, UpdaterState>,
|
updater: State<'_, LauncherUpdater>,
|
||||||
) -> Result<UpdateStatus, String> {
|
) -> Result<UpdateStatus, String> {
|
||||||
if state.critical() {
|
let status = updater.status(&app)?;
|
||||||
return Ok(state.status());
|
if !status.supported || status.stage == Stage::Ready {
|
||||||
}
|
|
||||||
Ok(pending(&app, &state)?.unwrap_or_else(|| state.status()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub(crate) async fn updater_check(
|
|
||||||
app: AppHandle,
|
|
||||||
state: State<'_, UpdaterState>,
|
|
||||||
) -> Result<UpdateStatus, String> {
|
|
||||||
let permit = state.acquire()?;
|
|
||||||
if let Some(status) = pending(&app, &state)? {
|
|
||||||
return Ok(status);
|
return Ok(status);
|
||||||
}
|
}
|
||||||
let state = state.inner().clone();
|
let permit = updater
|
||||||
let worker_app = app.clone();
|
.operation
|
||||||
let worker_state = state.clone();
|
.acquire("Проверка или установка обновления")
|
||||||
let worker = tauri::async_runtime::spawn_blocking(move || {
|
.map_err(|_| "Проверка или установка обновления уже выполняется.".to_string())?;
|
||||||
|
let updater = updater.inner().clone();
|
||||||
|
updater.set_stage(Stage::Checking)?;
|
||||||
|
// Detached task owns the permit: cancelling an IPC caller cannot release
|
||||||
|
// the operation while its native HTTP request is still in flight.
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
// Publish failure from the worker even if its IPC caller disappeared.
|
let result = async {
|
||||||
contain_worker_panic(
|
let key = updater::public_key(&app)?;
|
||||||
|| updater::check(&worker_app, &worker_state),
|
updater::check_candidate(
|
||||||
"Проверка обновления прервалась. Повторите проверку.",
|
updater::trusted_builder(&app)?,
|
||||||
)
|
&key,
|
||||||
.unwrap_or_else(|error| report_failure(&worker_app, &worker_state, error))
|
&app.package_info().version.to_string(),
|
||||||
});
|
updater::installation_kind(&app),
|
||||||
Ok(worker.await.unwrap_or_else(|_| {
|
)
|
||||||
report_failure(
|
.await
|
||||||
&app,
|
}
|
||||||
&state,
|
.await;
|
||||||
"Проверка обновления прервалась. Повторите проверку.".into(),
|
{
|
||||||
)
|
let mut state = updater
|
||||||
}))
|
.state
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||||
|
match result {
|
||||||
|
Ok(candidate) => {
|
||||||
|
state.stage = if candidate.is_some() {
|
||||||
|
Stage::Available
|
||||||
|
} else {
|
||||||
|
Stage::Idle
|
||||||
|
};
|
||||||
|
state.candidate = candidate;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
state.stage = if state.candidate.is_some() {
|
||||||
|
Stage::Available
|
||||||
|
} else {
|
||||||
|
Stage::Idle
|
||||||
|
};
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updater.status(&app)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| "Проверка обновления завершилась с ошибкой.".to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn updater_download_install(
|
pub(crate) async fn install_launcher_update(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
state: State<'_, UpdaterState>,
|
updater: State<'_, LauncherUpdater>,
|
||||||
) -> Result<UpdateStatus, String> {
|
operations: State<'_, LauncherOperations>,
|
||||||
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::<LauncherOperations>();
|
|
||||||
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> {
|
) -> Result<(), String> {
|
||||||
let _permit = state.acquire()?;
|
if let Some(reason) = updater::unsupported_reason(&app) {
|
||||||
if !state.ready() {
|
return Err(reason);
|
||||||
return Err("Нет завершённого обновления для перезапуска".into());
|
}
|
||||||
|
let permit = updater
|
||||||
|
.operation
|
||||||
|
.acquire("Проверка или установка обновления")
|
||||||
|
.map_err(|_| "Проверка или установка обновления уже выполняется.".to_string())?;
|
||||||
|
let candidate = {
|
||||||
|
let state = updater
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||||
|
if state.stage == Stage::Ready {
|
||||||
|
return Err("Обновление уже установлено. Перезапустите лаунчер.".into());
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.candidate
|
||||||
|
.clone()
|
||||||
|
.ok_or("Сначала проверьте наличие обновлений.")?
|
||||||
|
};
|
||||||
|
let destination = updater::installation_path(&app)?;
|
||||||
|
let mutation_permits = operations.acquire_update()
|
||||||
|
.map_err(|_| "Закройте Minecraft и дождитесь завершения установки или входа в аккаунт перед обновлением.".to_string())?;
|
||||||
|
let updater = updater.inner().clone();
|
||||||
|
updater.set_stage(Stage::Downloading)?;
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let _permit = permit;
|
||||||
|
let result = async {
|
||||||
|
let key = updater::public_key(&app)?;
|
||||||
|
let _ = app.emit(
|
||||||
|
"launcher-update-progress",
|
||||||
|
UpdateProgress {
|
||||||
|
stage: Stage::Downloading,
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
total_bytes: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let bytes =
|
||||||
|
updater::download_verified(&candidate, &key, |downloaded_bytes, total_bytes| {
|
||||||
|
let _ = app.emit(
|
||||||
|
"launcher-update-progress",
|
||||||
|
UpdateProgress {
|
||||||
|
stage: Stage::Downloading,
|
||||||
|
downloaded_bytes,
|
||||||
|
total_bytes,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let size = bytes.len() as u64;
|
||||||
|
updater.set_stage(Stage::Installing)?;
|
||||||
|
let _ = app.emit(
|
||||||
|
"launcher-update-progress",
|
||||||
|
UpdateProgress {
|
||||||
|
stage: Stage::Installing,
|
||||||
|
downloaded_bytes: size,
|
||||||
|
total_bytes: Some(size),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
updater::install_verified(&candidate, &bytes, &key, destination.as_deref())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| "Установка обновления завершилась с ошибкой.".to_string())??;
|
||||||
|
Ok::<_, String>(size)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(size) => {
|
||||||
|
let mut state = updater
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||||
|
state.stage = Stage::Ready;
|
||||||
|
state.restart_permits = Some(mutation_permits);
|
||||||
|
let _ = app.emit(
|
||||||
|
"launcher-update-progress",
|
||||||
|
UpdateProgress {
|
||||||
|
stage: Stage::Ready,
|
||||||
|
downloaded_bytes: size,
|
||||||
|
total_bytes: Some(size),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
updater.set_stage(Stage::Available)?;
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| "Установка обновления завершилась с ошибкой.".to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn restart_launcher_after_update(
|
||||||
|
app: AppHandle,
|
||||||
|
updater: State<'_, LauncherUpdater>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let _permit = updater
|
||||||
|
.operation
|
||||||
|
.acquire("Установка обновления")
|
||||||
|
.map_err(|_| "Установка обновления ещё выполняется.".to_string())?;
|
||||||
|
{
|
||||||
|
let state = updater
|
||||||
|
.state
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
|
||||||
|
if state.stage != Stage::Ready || state.restart_permits.is_none() {
|
||||||
|
return Err("Сначала установите обновление лаунчера.".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if updater::installation_kind(&app) == updater::InstallationKind::Deb
|
||||||
|
|| crate::deb_updater::is_deleted_installed_binary()
|
||||||
|
{
|
||||||
|
crate::deb_updater::restart()?;
|
||||||
|
app.exit(0);
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
app.restart()
|
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,555 @@
|
|||||||
|
//! The only elevated updater operation. pkexec starts this installed, root-owned
|
||||||
|
//! binary in an early non-GUI mode. Input is untrusted until BOTH signatures
|
||||||
|
//! are verified again here. No user-supplied path, command or password is used.
|
||||||
|
use crate::updater::{self, InstallationKind, MAX_ARTIFACT_BYTES, MAX_METADATA_BYTES};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
io::{self, Read, Write},
|
||||||
|
os::unix::{
|
||||||
|
fs::{MetadataExt, PermissionsExt},
|
||||||
|
process::CommandExt,
|
||||||
|
},
|
||||||
|
path::Path,
|
||||||
|
process::{Command, ExitStatus, Stdio},
|
||||||
|
sync::mpsc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use tauri_plugin_updater::Update;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
const BINARY: &str = "/usr/bin/shacraft-launcher";
|
||||||
|
const HELPER_FLAG: &str = "--shacraft-install-deb";
|
||||||
|
const PACKAGE: &str = "sha-craft-launcher";
|
||||||
|
const PKEXEC: &str = "/usr/bin/pkexec";
|
||||||
|
const DPKG: &str = "/usr/bin/dpkg";
|
||||||
|
const QUERY: &str = "/usr/bin/dpkg-query";
|
||||||
|
const DEB: &str = "/usr/bin/dpkg-deb";
|
||||||
|
const OUTPUT_LIMIT: usize = 16 * 1024;
|
||||||
|
const INPUT_MAGIC: &[u8; 8] = b"SCDUPD01";
|
||||||
|
const REJECTED: i32 = 20;
|
||||||
|
const LOCKED: i32 = 21;
|
||||||
|
const INSTALL_FAILED: i32 = 22;
|
||||||
|
const INVALID_HOST: i32 = 23;
|
||||||
|
|
||||||
|
fn architecture() -> &'static str {
|
||||||
|
match std::env::consts::ARCH {
|
||||||
|
"x86_64" => "amd64",
|
||||||
|
"aarch64" => "arm64",
|
||||||
|
_ => "unsupported",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the full path without following symlinks. The executable and every
|
||||||
|
/// parent must be root-owned and not writable by group/other users.
|
||||||
|
fn trusted_root_path(path: &Path, executable: bool) -> bool {
|
||||||
|
if !path.is_absolute()
|
||||||
|
|| path
|
||||||
|
.components()
|
||||||
|
.any(|c| matches!(c, std::path::Component::ParentDir))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut leaf = true;
|
||||||
|
for item in path.ancestors() {
|
||||||
|
let Ok(meta) = fs::symlink_metadata(item) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if meta.file_type().is_symlink() || meta.uid() != 0 || meta.mode() & 0o022 != 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if leaf && executable {
|
||||||
|
if !meta.is_file() || meta.mode() & 0o111 == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if !meta.is_dir() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
leaf = false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_sticky_temporary_parent(path: &Path) -> bool {
|
||||||
|
fs::symlink_metadata(path).is_ok_and(|meta| {
|
||||||
|
meta.is_dir()
|
||||||
|
&& !meta.file_type().is_symlink()
|
||||||
|
&& meta.uid() == 0
|
||||||
|
&& (meta.mode() & 0o022 == 0 || meta.mode() & 0o1000 != 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixed_command(path: &str) -> Command {
|
||||||
|
let mut command = Command::new(path);
|
||||||
|
command
|
||||||
|
.env_clear()
|
||||||
|
.env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin")
|
||||||
|
.env("LC_ALL", "C");
|
||||||
|
command
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_capped(mut source: impl Read) -> io::Result<Vec<u8>> {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut buffer = [0_u8; 4096];
|
||||||
|
loop {
|
||||||
|
let read = source.read(&mut buffer)?;
|
||||||
|
if read == 0 {
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
let remaining = OUTPUT_LIMIT.saturating_sub(result.len());
|
||||||
|
result.extend_from_slice(&buffer[..read.min(remaining)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Captured {
|
||||||
|
status: ExitStatus,
|
||||||
|
stdout: Vec<u8>,
|
||||||
|
stderr: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain both pipes concurrently; output can never make the root helper buffer
|
||||||
|
/// unbounded data or deadlock dpkg while it is changing the package database.
|
||||||
|
fn capture(command: &mut Command) -> io::Result<Captured> {
|
||||||
|
capture_with_deadline(command, Duration::from_secs(15))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture_with_deadline(command: &mut Command, deadline: Duration) -> io::Result<Captured> {
|
||||||
|
// Read-only inspection has a deadline. Never kill dpkg during mutation:
|
||||||
|
// interrupting it could leave a partially configured installed package.
|
||||||
|
let inspection = command.get_program() != DPKG;
|
||||||
|
if inspection {
|
||||||
|
command.process_group(0);
|
||||||
|
}
|
||||||
|
let mut child = command
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
let stdout = child.stdout.take().expect("piped stdout");
|
||||||
|
let stderr = child.stderr.take().expect("piped stderr");
|
||||||
|
let (out_send, out_receive) = mpsc::channel();
|
||||||
|
let (err_send, err_receive) = mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = out_send.send(drain_capped(stdout));
|
||||||
|
});
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = err_send.send(drain_capped(stderr));
|
||||||
|
});
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut stdout = None;
|
||||||
|
let mut stderr = None;
|
||||||
|
loop {
|
||||||
|
if stdout.is_none() {
|
||||||
|
stdout = out_receive.try_recv().ok();
|
||||||
|
}
|
||||||
|
if stderr.is_none() {
|
||||||
|
stderr = err_receive.try_recv().ok();
|
||||||
|
}
|
||||||
|
// Do not reap the parent before its pipes close. Its unreaped PID
|
||||||
|
// reserves the process-group id until a possible timeout kill below.
|
||||||
|
if stdout.is_some() && stderr.is_some() {
|
||||||
|
if let Some(status) = child.try_wait()? {
|
||||||
|
return Ok(Captured {
|
||||||
|
status,
|
||||||
|
stdout: stdout.unwrap()?,
|
||||||
|
stderr: stderr.unwrap()?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inspection && start.elapsed() > deadline {
|
||||||
|
// Also terminate dpkg-deb's decompressor descendants so they cannot
|
||||||
|
// retain the pipes after the inspection parent has been killed.
|
||||||
|
unsafe {
|
||||||
|
libc::kill(-(child.id() as i32), libc::SIGKILL);
|
||||||
|
}
|
||||||
|
let _ = child.wait();
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::TimedOut,
|
||||||
|
"package inspection timed out",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn installed_version() -> Result<String, ()> {
|
||||||
|
let result = capture(fixed_command(QUERY).args([
|
||||||
|
"--show",
|
||||||
|
"--showformat=${db:Status-Status}\n${Version}\n${Architecture}\n",
|
||||||
|
PACKAGE,
|
||||||
|
]))
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
if !result.status.success() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
let fields = std::str::from_utf8(&result.stdout)
|
||||||
|
.map_err(|_| ())?
|
||||||
|
.lines()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if fields.len() != 3 || fields[0] != "installed" || fields[2] != architecture() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
let version = semver::Version::parse(fields[1]).map_err(|_| ())?;
|
||||||
|
if version.to_string() != fields[1] || !version.pre.is_empty() || !version.build.is_empty() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
// dpkg's database must also assign the precise executable to our package.
|
||||||
|
let owner = capture(fixed_command(QUERY).args(["--search", BINARY])).map_err(|_| ())?;
|
||||||
|
if !owner.status.success() || owner.stdout != format!("{PACKAGE}: {BINARY}\n").as_bytes() {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
Ok(fields[1].to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn installed_binary_supported() -> bool {
|
||||||
|
std::env::current_exe().is_ok_and(|path| path == Path::new(BINARY))
|
||||||
|
&& trusted_root_path(Path::new(BINARY), true)
|
||||||
|
&& [QUERY, DEB, DPKG]
|
||||||
|
.iter()
|
||||||
|
.all(|path| trusted_root_path(Path::new(path), true))
|
||||||
|
&& installed_version().is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unsupported_reason() -> Option<String> {
|
||||||
|
if trusted_root_path(Path::new(PKEXEC), true) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some("Для обновления deb нужен системный компонент pkexec (PolicyKit). Установите его или скачайте новый deb с shacraft.ru/help#launcher.".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_deleted_installed_binary() -> bool {
|
||||||
|
std::env::current_exe()
|
||||||
|
.is_ok_and(|path| path == Path::new("/usr/bin/shacraft-launcher (deleted)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn restart() -> Result<(), String> {
|
||||||
|
if !trusted_root_path(Path::new(BINARY), true) {
|
||||||
|
return Err("Установленный лаунчер недоступен. Запустите его из меню приложений.".into());
|
||||||
|
}
|
||||||
|
Command::new(BINARY).spawn().map_err(|_| {
|
||||||
|
"Не удалось перезапустить лаунчер. Запустите его из меню приложений.".to_string()
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_input(mut output: impl Write, metadata: &[u8], bytes: &[u8]) -> io::Result<()> {
|
||||||
|
if metadata.len() > MAX_METADATA_BYTES || bytes.len() > MAX_ARTIFACT_BYTES {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"update exceeds input limit",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
output.write_all(INPUT_MAGIC)?;
|
||||||
|
output.write_all(&(metadata.len() as u64).to_be_bytes())?;
|
||||||
|
output.write_all(metadata)?;
|
||||||
|
output.write_all(&(bytes.len() as u64).to_be_bytes())?;
|
||||||
|
output.write_all(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_part(input: &mut impl Read, maximum: usize) -> io::Result<Vec<u8>> {
|
||||||
|
let mut length = [0_u8; 8];
|
||||||
|
input.read_exact(&mut length)?;
|
||||||
|
let length = u64::from_be_bytes(length);
|
||||||
|
if length == 0 || length > maximum as u64 {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"invalid update input length",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut bytes = vec![0; length as usize];
|
||||||
|
input.read_exact(&mut bytes)?;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_input(mut input: impl Read) -> io::Result<(Value, Vec<u8>)> {
|
||||||
|
let mut magic = [0_u8; 8];
|
||||||
|
input.read_exact(&mut magic)?;
|
||||||
|
if &magic != INPUT_MAGIC {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"invalid protocol",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let metadata = read_part(&mut input, MAX_METADATA_BYTES)?;
|
||||||
|
let bytes = read_part(&mut input, MAX_ARTIFACT_BYTES)?;
|
||||||
|
let mut trailing = [0_u8];
|
||||||
|
if input.read(&mut trailing)? != 0 {
|
||||||
|
return Err(io::Error::new(io::ErrorKind::InvalidData, "trailing input"));
|
||||||
|
}
|
||||||
|
let raw = serde_json::from_slice(&metadata)
|
||||||
|
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid metadata"))?;
|
||||||
|
Ok((raw, bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exit_message(code: Option<i32>) -> String {
|
||||||
|
match code {
|
||||||
|
Some(0) => "",
|
||||||
|
Some(126) => "Установка отменена в системном окне. Текущая версия лаунчера сохранена.",
|
||||||
|
Some(127) => "Система не разрешила установку. Подтвердите права администратора в системном окне; при его отсутствии проверьте PolicyKit.",
|
||||||
|
Some(REJECTED) => "Системная проверка подписи или версии deb не пройдена. Установка отменена.",
|
||||||
|
Some(LOCKED) => "Пакетный менеджер занят другой установкой. Дождитесь её завершения и нажмите «Обновить» ещё раз.",
|
||||||
|
Some(INVALID_HOST) => "Системная установка ShaCraft не подтверждена. Установите новый deb вручную с shacraft.ru/help#launcher.",
|
||||||
|
_ => "Пакетный менеджер не завершил установку. Проверьте состояние пакетов в системе и повторите попытку; при необходимости установите deb вручную.",
|
||||||
|
}.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn install(update: &Update, bytes: &[u8]) -> Result<(), String> {
|
||||||
|
if !installed_binary_supported() {
|
||||||
|
return Err(exit_message(Some(INVALID_HOST)));
|
||||||
|
}
|
||||||
|
if let Some(reason) = unsupported_reason() {
|
||||||
|
return Err(reason);
|
||||||
|
}
|
||||||
|
let metadata =
|
||||||
|
serde_json::to_vec(&update.raw_json).map_err(|_| exit_message(Some(REJECTED)))?;
|
||||||
|
let mut child = Command::new(PKEXEC)
|
||||||
|
.args(["--disable-internal-agent", BINARY, HELPER_FLAG])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|_| exit_message(Some(127)))?;
|
||||||
|
// Always wait even on EPIPE: declining the system dialog closes stdin, and
|
||||||
|
// its exit status is the useful cancellation result, not "broken pipe".
|
||||||
|
let write_result = write_input(
|
||||||
|
child.stdin.take().expect("piped helper input"),
|
||||||
|
&metadata,
|
||||||
|
bytes,
|
||||||
|
);
|
||||||
|
let status = child.wait().map_err(|_| exit_message(None))?;
|
||||||
|
if status.success() && write_result.is_ok() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(exit_message(status.code().filter(|code| *code != 0)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_deb_release(raw: &Value, bytes: &[u8], key: &str, installed: &str) -> Result<String, ()> {
|
||||||
|
let metadata = updater::verified_metadata(raw, key).map_err(|_| ())?;
|
||||||
|
if !updater::newer_version(&metadata, installed).map_err(|_| ())? {
|
||||||
|
return Err(());
|
||||||
|
}
|
||||||
|
let version = metadata["version"].as_str().ok_or(())?;
|
||||||
|
let target = format!("linux-{}-deb", std::env::consts::ARCH);
|
||||||
|
let artifact = metadata["platforms"].get(&target).ok_or(())?;
|
||||||
|
let url = Url::parse(artifact["url"].as_str().ok_or(())?).map_err(|_| ())?;
|
||||||
|
updater::validate_download_url(&url, version, InstallationKind::Deb).map_err(|_| ())?;
|
||||||
|
updater::verify_signature(bytes, artifact["signature"].as_str().ok_or(())?, key)
|
||||||
|
.map_err(|_| ())?;
|
||||||
|
Ok(version.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_package_fields(output: &[u8], version: &str) -> bool {
|
||||||
|
std::str::from_utf8(output)
|
||||||
|
.is_ok_and(|text| text == format!("{PACKAGE}\n{version}\n{}\n", architecture()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_error(stderr: &[u8]) -> bool {
|
||||||
|
let text = String::from_utf8_lossy(stderr).to_ascii_lowercase();
|
||||||
|
(text.contains("lock")
|
||||||
|
&& (text.contains("locked")
|
||||||
|
|| text.contains("another process")
|
||||||
|
|| text.contains("resource temporarily unavailable")
|
||||||
|
|| text.contains("unable to acquire")))
|
||||||
|
|| text.contains("dpkg frontend lock was locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn embedded_key() -> Result<String, ()> {
|
||||||
|
let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")).map_err(|_| ())?;
|
||||||
|
config["plugins"]["updater"]["pubkey"]
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.ok_or(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_helper() -> Result<(), i32> {
|
||||||
|
// pkexec cleans the environment before executing this root-owned program.
|
||||||
|
// Never initialize Tauri/GTK or network/account code in privileged mode.
|
||||||
|
if unsafe { libc::geteuid() } != 0 || !installed_binary_supported() {
|
||||||
|
return Err(INVALID_HOST);
|
||||||
|
}
|
||||||
|
let installed = installed_version().map_err(|_| INVALID_HOST)?;
|
||||||
|
let (raw, bytes) = read_input(io::stdin().lock()).map_err(|_| REJECTED)?;
|
||||||
|
let version = verify_deb_release(
|
||||||
|
&raw,
|
||||||
|
&bytes,
|
||||||
|
&embedded_key().map_err(|_| REJECTED)?,
|
||||||
|
&installed,
|
||||||
|
)
|
||||||
|
.map_err(|_| REJECTED)?;
|
||||||
|
// No untrusted filesystem object crosses the privilege boundary. This
|
||||||
|
// directory is created by root, mode 0700, after all signature checks.
|
||||||
|
if !trusted_root_path(Path::new("/var"), false)
|
||||||
|
|| !safe_sticky_temporary_parent(Path::new("/var/tmp"))
|
||||||
|
{
|
||||||
|
return Err(INVALID_HOST);
|
||||||
|
}
|
||||||
|
let temp = tempfile::Builder::new()
|
||||||
|
.prefix("shacraft-update-")
|
||||||
|
.tempdir_in("/var/tmp")
|
||||||
|
.map_err(|_| INSTALL_FAILED)?;
|
||||||
|
fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700))
|
||||||
|
.map_err(|_| INSTALL_FAILED)?;
|
||||||
|
let package = temp.path().join("release.deb");
|
||||||
|
let mut output = fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&package)
|
||||||
|
.map_err(|_| INSTALL_FAILED)?;
|
||||||
|
output
|
||||||
|
.set_permissions(fs::Permissions::from_mode(0o600))
|
||||||
|
.map_err(|_| INSTALL_FAILED)?;
|
||||||
|
output
|
||||||
|
.write_all(&bytes)
|
||||||
|
.and_then(|_| output.sync_all())
|
||||||
|
.map_err(|_| INSTALL_FAILED)?;
|
||||||
|
drop(output);
|
||||||
|
let fields = capture(
|
||||||
|
fixed_command(DEB)
|
||||||
|
.arg("--show")
|
||||||
|
.arg("--showformat=${Package}\n${Version}\n${Architecture}\n")
|
||||||
|
.arg(&package),
|
||||||
|
)
|
||||||
|
.map_err(|_| REJECTED)?;
|
||||||
|
if !fields.status.success() || !valid_package_fields(&fields.stdout, &version) {
|
||||||
|
return Err(REJECTED);
|
||||||
|
}
|
||||||
|
// Check again immediately before mutation: another updater might have
|
||||||
|
// installed the release while the authentication dialog was open.
|
||||||
|
if !updater::newer_version(
|
||||||
|
&serde_json::json!({"version": version}),
|
||||||
|
&installed_version().map_err(|_| INVALID_HOST)?,
|
||||||
|
)
|
||||||
|
.map_err(|_| REJECTED)?
|
||||||
|
{
|
||||||
|
return Err(REJECTED);
|
||||||
|
}
|
||||||
|
let result = capture(
|
||||||
|
fixed_command(DPKG)
|
||||||
|
.args(["--refuse-downgrade", "--install"])
|
||||||
|
.arg(&package),
|
||||||
|
)
|
||||||
|
.map_err(|_| INSTALL_FAILED)?;
|
||||||
|
if !result.status.success() {
|
||||||
|
return Err(if lock_error(&result.stderr) {
|
||||||
|
LOCKED
|
||||||
|
} else {
|
||||||
|
INSTALL_FAILED
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if installed_version().map_err(|_| INSTALL_FAILED)? != version {
|
||||||
|
return Err(INSTALL_FAILED);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The special flag is never registered as IPC and does not accept filenames.
|
||||||
|
/// Even manually invoking it cannot bypass signatures, package identity or
|
||||||
|
/// privilege checks. Errors intentionally print no package/metadata contents.
|
||||||
|
pub(crate) fn run_helper_if_requested() -> Option<i32> {
|
||||||
|
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||||
|
if !arguments.iter().any(|argument| argument == HELPER_FLAG) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if arguments.len() != 1 || arguments[0] != HELPER_FLAG {
|
||||||
|
return Some(REJECTED);
|
||||||
|
}
|
||||||
|
Some(match run_helper() {
|
||||||
|
Ok(()) => 0,
|
||||||
|
Err(code) => code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn framed_input_rejects_oversized_truncated_and_trailing_data() {
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
write_input(&mut bytes, b"{}", b"package").unwrap();
|
||||||
|
let (metadata, package) = read_input(&bytes[..]).unwrap();
|
||||||
|
assert_eq!(metadata, serde_json::json!({}));
|
||||||
|
assert_eq!(package, b"package");
|
||||||
|
assert!(read_input(&bytes[..bytes.len() - 1]).is_err());
|
||||||
|
bytes.push(0);
|
||||||
|
assert!(read_input(&bytes[..]).is_err());
|
||||||
|
let mut oversized = INPUT_MAGIC.to_vec();
|
||||||
|
oversized.extend_from_slice(&u64::MAX.to_be_bytes());
|
||||||
|
assert!(read_input(&oversized[..]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn package_identity_version_architecture_are_exact() {
|
||||||
|
let good = format!("{PACKAGE}\n0.1.4\n{}\n", architecture());
|
||||||
|
assert!(valid_package_fields(good.as_bytes(), "0.1.4"));
|
||||||
|
for wrong in [
|
||||||
|
good.replace(PACKAGE, "another-package"),
|
||||||
|
good.replace("0.1.4", "0.1.5"),
|
||||||
|
good.replace(architecture(), "all"),
|
||||||
|
format!("{good}extra\n"),
|
||||||
|
] {
|
||||||
|
assert!(!valid_package_fields(wrong.as_bytes(), "0.1.4"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancellation_authorization_and_package_lock_remain_distinct() {
|
||||||
|
assert!(exit_message(Some(126)).contains("отменена"));
|
||||||
|
assert!(exit_message(Some(127)).contains("не разрешила"));
|
||||||
|
assert!(exit_message(Some(LOCKED)).contains("занят"));
|
||||||
|
assert!(lock_error(
|
||||||
|
b"dpkg: error: dpkg frontend lock was locked by another process"
|
||||||
|
));
|
||||||
|
assert!(!lock_error(
|
||||||
|
b"dpkg: dependency problems prevent configuration"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn privileged_path_rejects_user_owned_files_symlinks_and_relative_paths() {
|
||||||
|
let directory = tempfile::tempdir().unwrap();
|
||||||
|
let file = directory.path().join("launcher");
|
||||||
|
fs::write(&file, b"file").unwrap();
|
||||||
|
fs::set_permissions(&file, fs::Permissions::from_mode(0o777)).unwrap();
|
||||||
|
assert!(!trusted_root_path(&file, true));
|
||||||
|
let link = directory.path().join("link");
|
||||||
|
std::os::unix::fs::symlink("/usr/bin/dpkg", &link).unwrap();
|
||||||
|
assert!(!trusted_root_path(&link, true));
|
||||||
|
assert!(!trusted_root_path(Path::new("usr/bin/dpkg"), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn root_verification_does_not_accept_legacy_appimage_as_deb() {
|
||||||
|
let fixture: Value =
|
||||||
|
serde_json::from_str(include_str!("../tests/fixtures/updater-signed.json")).unwrap();
|
||||||
|
assert!(verify_deb_release(
|
||||||
|
&fixture["metadata"],
|
||||||
|
fixture["artifactText"].as_str().unwrap().as_bytes(),
|
||||||
|
fixture["publicKey"].as_str().unwrap(),
|
||||||
|
"0.0.0"
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inspection_timeout_kills_descendants_holding_output_pipes() {
|
||||||
|
let start = Instant::now();
|
||||||
|
let result = capture_with_deadline(
|
||||||
|
Command::new("/bin/sh").args(["-c", "sleep 30 & exit 0"]),
|
||||||
|
Duration::from_millis(100),
|
||||||
|
);
|
||||||
|
assert!(matches!(result, Err(error) if error.kind() == io::ErrorKind::TimedOut));
|
||||||
|
assert!(start.elapsed() < Duration::from_secs(3));
|
||||||
|
let output = capture(fixed_command(DEB).arg("--version")).unwrap();
|
||||||
|
assert!(output.status.success());
|
||||||
|
assert!(String::from_utf8_lossy(&output.stdout).contains("Debian"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_output_is_bounded_and_fully_drained() {
|
||||||
|
let input = vec![b'x'; OUTPUT_LIMIT * 4];
|
||||||
|
assert_eq!(drain_capped(input.as_slice()).unwrap().len(), OUTPUT_LIMIT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//! Fabric metadata and Maven are fixed, independent game trust domains.
|
||||||
|
//! The signed ShaCraft manifest selects versions, never arbitrary loader URLs.
|
||||||
|
use crate::mojang::{Artifact, LibraryDownloads, VersionJson};
|
||||||
|
use reqwest::blocking::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::{io::Read, time::Duration};
|
||||||
|
|
||||||
|
pub(crate) const MAVEN_HOST: &str = "maven.fabricmc.net";
|
||||||
|
const HOSTS: [&str; 2] = ["meta.fabricmc.net", MAVEN_HOST];
|
||||||
|
const MAX_PROFILE: usize = 512 * 1024;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FabricLibrary {
|
||||||
|
name: String,
|
||||||
|
url: String,
|
||||||
|
sha1: Option<String>,
|
||||||
|
size: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn coordinate_path(name: &str) -> Result<String, String> {
|
||||||
|
let pieces: Vec<_> = name.split(':').collect();
|
||||||
|
if pieces.len() != 3
|
||||||
|
|| pieces.iter().any(|p| {
|
||||||
|
!crate::manifest::is_portable_component(p)
|
||||||
|
|| *p == "."
|
||||||
|
|| *p == ".."
|
||||||
|
|| !p
|
||||||
|
.bytes()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || b"._+-".contains(&c))
|
||||||
|
})
|
||||||
|
|| pieces[0].split('.').any(|p| !crate::manifest::is_portable_component(p))
|
||||||
|
{
|
||||||
|
return Err("Invalid Fabric library coordinate".into());
|
||||||
|
}
|
||||||
|
Ok(format!(
|
||||||
|
"{}/{}/{}/{}-{}.jar",
|
||||||
|
pieces[0].replace('.', "/"),
|
||||||
|
pieces[1],
|
||||||
|
pieces[2],
|
||||||
|
pieces[1],
|
||||||
|
pieces[2]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(client: &Client, url: &str, limit: usize) -> Result<Vec<u8>, String> {
|
||||||
|
let response = client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
response
|
||||||
|
.take((limit + 1) as u64)
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if bytes.len() > limit {
|
||||||
|
return Err("Fabric metadata exceeds its size limit".into());
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fetch_profile(minecraft: &str, loader: &str) -> Result<VersionJson, String> {
|
||||||
|
let client =
|
||||||
|
crate::trusted_http::client(&HOSTS, Duration::from_secs(30)).map_err(|e| e.to_string())?;
|
||||||
|
// Defence in depth: these values normally already passed manifest validation.
|
||||||
|
if [minecraft, loader].iter().any(|v| {
|
||||||
|
!crate::manifest::is_portable_component(v)
|
||||||
|
|| v.contains('/')
|
||||||
|
|| !v
|
||||||
|
.bytes()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || b"._+-".contains(&c))
|
||||||
|
}) {
|
||||||
|
return Err("Invalid Fabric version".into());
|
||||||
|
}
|
||||||
|
let bytes = get(
|
||||||
|
&client,
|
||||||
|
&format!("https://meta.fabricmc.net/v2/versions/loader/{minecraft}/{loader}/profile/json"),
|
||||||
|
MAX_PROFILE,
|
||||||
|
)?;
|
||||||
|
normalize(&client, &bytes, minecraft, loader)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize(
|
||||||
|
client: &Client,
|
||||||
|
bytes: &[u8],
|
||||||
|
minecraft: &str,
|
||||||
|
loader: &str,
|
||||||
|
) -> Result<VersionJson, String> {
|
||||||
|
// Flattening libraries would consume the same key twice; parse the two views explicitly.
|
||||||
|
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
|
||||||
|
let parent = value.get("inheritsFrom").and_then(|v| v.as_str());
|
||||||
|
let mut version: VersionJson =
|
||||||
|
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
|
||||||
|
if parent != Some(minecraft)
|
||||||
|
|| version.id != format!("fabric-loader-{loader}-{minecraft}")
|
||||||
|
|| version.main_class != "net.fabricmc.loader.impl.launch.knot.KnotClient"
|
||||||
|
{
|
||||||
|
return Err("Fabric profile identity mismatch".into());
|
||||||
|
}
|
||||||
|
let artifacts: Vec<FabricLibrary> =
|
||||||
|
serde_json::from_value(value["libraries"].clone()).map_err(|e| e.to_string())?;
|
||||||
|
if artifacts.is_empty() || artifacts.len() > 32 {
|
||||||
|
return Err("Invalid Fabric library count".into());
|
||||||
|
}
|
||||||
|
for (library, artifact) in version.libraries.iter_mut().zip(artifacts) {
|
||||||
|
if artifact.url != "https://maven.fabricmc.net/" {
|
||||||
|
return Err("Untrusted Fabric Maven URL".into());
|
||||||
|
}
|
||||||
|
let path = coordinate_path(&artifact.name)?;
|
||||||
|
let url = format!("https://{MAVEN_HOST}/{path}");
|
||||||
|
let sha1 = match artifact.sha1 {
|
||||||
|
Some(hash) => hash,
|
||||||
|
None => String::from_utf8(get(client, &format!("{url}.sha1"), 128)?)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.trim()
|
||||||
|
.to_owned(),
|
||||||
|
};
|
||||||
|
let size = match artifact.size {
|
||||||
|
Some(size) => size,
|
||||||
|
None => client
|
||||||
|
.head(&url)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_LENGTH)
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
|
.ok_or("Fabric library has no size")?,
|
||||||
|
};
|
||||||
|
if sha1.len() != 40
|
||||||
|
|| !sha1.bytes().all(|b| b.is_ascii_hexdigit())
|
||||||
|
|| size == 0
|
||||||
|
|| size > 64 * 1024 * 1024
|
||||||
|
{
|
||||||
|
return Err("Invalid Fabric library hash or size".into());
|
||||||
|
}
|
||||||
|
library.downloads = Some(LibraryDownloads {
|
||||||
|
artifact: Some(Artifact {
|
||||||
|
path,
|
||||||
|
url,
|
||||||
|
sha1,
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn maven_coordinates_cannot_escape_library_directory() {
|
||||||
|
assert_eq!(
|
||||||
|
coordinate_path("net.fabricmc:fabric-loader:0.19.5").unwrap(),
|
||||||
|
"net/fabricmc/fabric-loader/0.19.5/fabric-loader-0.19.5.jar"
|
||||||
|
);
|
||||||
|
for bad in [
|
||||||
|
"x:y:../evil",
|
||||||
|
"a..b:c:1",
|
||||||
|
"x:/tmp:1",
|
||||||
|
"x:y:1:extra",
|
||||||
|
"x:y:\\evil",
|
||||||
|
"x:y:..",
|
||||||
|
"CON:y:1",
|
||||||
|
"a:y.:1",
|
||||||
|
"a:AUX:1",
|
||||||
|
] {
|
||||||
|
assert!(coordinate_path(bad).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn loader_identity_and_maven_origin_are_checked_before_downloads() {
|
||||||
|
let client = Client::new();
|
||||||
|
let base = serde_json::json!({"id":"fabric-loader-0.19.5-26.2", "inheritsFrom":"26.2", "mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient", "libraries":[{"name":"net.fabricmc:fabric-loader:0.19.5", "url":"https://maven.fabricmc.net/", "sha1":"a".repeat(40), "size":42}]});
|
||||||
|
assert!(normalize(
|
||||||
|
&client,
|
||||||
|
&serde_json::to_vec(&base).unwrap(),
|
||||||
|
"26.2",
|
||||||
|
"0.19.5"
|
||||||
|
)
|
||||||
|
.is_ok());
|
||||||
|
for (field, value) in [
|
||||||
|
("inheritsFrom", "1.21.1"),
|
||||||
|
("mainClass", "attacker.Main"),
|
||||||
|
("id", "wrong"),
|
||||||
|
] {
|
||||||
|
let mut bad = base.clone();
|
||||||
|
bad[field] = value.into();
|
||||||
|
assert!(normalize(
|
||||||
|
&client,
|
||||||
|
&serde_json::to_vec(&bad).unwrap(),
|
||||||
|
"26.2",
|
||||||
|
"0.19.5"
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
let mut bad = base;
|
||||||
|
bad["libraries"][0]["url"] = "https://attacker.invalid/".into();
|
||||||
|
assert!(normalize(
|
||||||
|
&client,
|
||||||
|
&serde_json::to_vec(&bad).unwrap(),
|
||||||
|
"26.2",
|
||||||
|
"0.19.5"
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
#[ignore = "downloads official Fabric profile metadata"]
|
||||||
|
fn live_resolves_fabric_26_2() {
|
||||||
|
let profile = fetch_profile("26.2", "0.19.5").unwrap();
|
||||||
|
assert!(profile
|
||||||
|
.libraries
|
||||||
|
.iter()
|
||||||
|
.all(|library| library.downloads.is_some()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
//! 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<Option<ProcessIdentity>, 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<Self, String> {
|
|
||||||
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<InstallationLock, String> {
|
|
||||||
fn unwrap_err_string(self) -> String {
|
|
||||||
match self {
|
|
||||||
Err(e) => e,
|
|
||||||
Ok(_) => panic!("expected lock refusal"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,602 +0,0 @@
|
|||||||
//! 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<String, OwnedFile>,
|
|
||||||
}
|
|
||||||
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<Fingerprint>,
|
|
||||||
pub after: Option<Fingerprint>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub(crate) struct Journal {
|
|
||||||
version: u32,
|
|
||||||
pub transaction: String,
|
|
||||||
pub changes: Vec<Change>,
|
|
||||||
pub next: Inventory,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct Store {
|
|
||||||
pub root: PathBuf,
|
|
||||||
profile: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn invalid(message: impl Into<String>) -> 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<PathBuf> {
|
|
||||||
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<Option<Fingerprint>> {
|
|
||||||
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<T: for<'de> Deserialize<'de>>(path: &Path) -> io::Result<Option<T>> {
|
|
||||||
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<Self> {
|
|
||||||
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<Inventory> {
|
|
||||||
let inventory = read_json(&self.root.join("inventory.json"))?.unwrap_or_default();
|
|
||||||
validate_inventory(&inventory)?;
|
|
||||||
Ok(inventory)
|
|
||||||
}
|
|
||||||
pub fn pending(&self) -> io::Result<bool> {
|
|
||||||
Ok(self.read_journal()?.is_some())
|
|
||||||
}
|
|
||||||
fn read_journal(&self) -> io::Result<Option<Journal>> {
|
|
||||||
let journal: Option<Journal> = 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<String> {
|
|
||||||
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<PathBuf> {
|
|
||||||
checked_path(&self.root, &format!("{transaction}/staged/{index}"))
|
|
||||||
}
|
|
||||||
pub fn prepare(
|
|
||||||
&self,
|
|
||||||
transaction: String,
|
|
||||||
changes: Vec<Change>,
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+6
-57
@@ -60,28 +60,18 @@ pub fn ensure_java(
|
|||||||
required_major: u8,
|
required_major: u8,
|
||||||
on_progress: &ProgressCallback,
|
on_progress: &ProgressCallback,
|
||||||
) -> Result<JavaInstallation, EnsureJavaError> {
|
) -> Result<JavaInstallation, EnsureJavaError> {
|
||||||
if let Some(installation) = find_matching(candidates(), required_major, check_candidate) {
|
if let Some(installation) = detect() {
|
||||||
on_progress(1, 1);
|
if installation.major == required_major {
|
||||||
return Ok(installation);
|
on_progress(1, 1);
|
||||||
|
return Ok(installation);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress)
|
let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress)
|
||||||
.map_err(EnsureJavaError::Provisioning)?;
|
.map_err(EnsureJavaError::Provisioning)?;
|
||||||
check_candidate(executable.clone())
|
check_candidate(executable.clone())
|
||||||
.filter(|installation| installation.major == required_major)
|
|
||||||
.ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
|
.ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_matching(
|
|
||||||
candidates: impl IntoIterator<Item = PathBuf>,
|
|
||||||
required_major: u8,
|
|
||||||
mut inspect: impl FnMut(PathBuf) -> Option<JavaInstallation>,
|
|
||||||
) -> Option<JavaInstallation> {
|
|
||||||
candidates
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(&mut inspect)
|
|
||||||
.find(|installation| installation.major == required_major)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn candidates() -> Vec<PathBuf> {
|
fn candidates() -> Vec<PathBuf> {
|
||||||
let executable = if cfg!(target_os = "windows") {
|
let executable = if cfg!(target_os = "windows") {
|
||||||
"java.exe"
|
"java.exe"
|
||||||
@@ -137,48 +127,7 @@ fn parse_major(version: &str) -> Option<u8> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::{parse_major, parse_version};
|
||||||
|
|
||||||
#[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]
|
#[test]
|
||||||
fn parses_modern_java_version() {
|
fn parses_modern_java_version() {
|
||||||
|
|||||||
+90
-23
@@ -1,11 +1,10 @@
|
|||||||
//! Builds and spawns the real `java` invocation for a merged launch
|
//! Builds and spawns the real `java` invocation for a merged launch
|
||||||
//! profile. The `${auth_*}` placeholders are filled from a `PlayerIdentity`,
|
//! profile. The `${auth_*}` placeholders use only the identity bound to the
|
||||||
//! which is either a real Microsoft-authenticated session (`msa::LoginResult`)
|
//! server-issued admission ticket. Its one-use proof stays out of arguments
|
||||||
//! or an explicit offline account (`PlayerIdentity::Offline`). Offline mode is
|
//! and files; only the Java child's environment receives it.
|
||||||
//! never silently substituted for a Microsoft session.
|
|
||||||
|
|
||||||
|
use crate::admission::Admission;
|
||||||
use crate::mojang::{self, MergedVersion};
|
use crate::mojang::{self, MergedVersion};
|
||||||
use crate::session::PlayerIdentity;
|
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
@@ -44,11 +43,9 @@ pub struct LaunchRequest<'a> {
|
|||||||
/// the shared `game_dir`.
|
/// the shared `game_dir`.
|
||||||
pub profile_dir: &'a Path,
|
pub profile_dir: &'a Path,
|
||||||
pub merged: &'a MergedVersion,
|
pub merged: &'a MergedVersion,
|
||||||
pub identity: &'a PlayerIdentity,
|
pub admission: &'a Admission,
|
||||||
pub memory_mb: u16,
|
pub memory_mb: u16,
|
||||||
pub log_path: &'a Path,
|
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 {
|
fn classpath_separator() -> &'static str {
|
||||||
@@ -185,6 +182,10 @@ fn write_jvm_argfile(path: &Path, arguments: &[String]) -> io::Result<()> {
|
|||||||
/// that (see `lib.rs`'s launch command, which watches it on a background
|
/// that (see `lib.rs`'s launch command, which watches it on a background
|
||||||
/// thread and emits an event).
|
/// thread and emits an event).
|
||||||
pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
||||||
|
Ok(build_command(request)?.spawn()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_command(request: &LaunchRequest) -> Result<Command, LaunchError> {
|
||||||
fs::create_dir_all(request.profile_dir)?;
|
fs::create_dir_all(request.profile_dir)?;
|
||||||
let natives_dir = mojang::natives_directory(request.game_dir, &request.merged.id);
|
let natives_dir = mojang::natives_directory(request.game_dir, &request.merged.id);
|
||||||
fs::create_dir_all(&natives_dir)?;
|
fs::create_dir_all(&natives_dir)?;
|
||||||
@@ -195,7 +196,8 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
|||||||
let classpath = build_classpath(request.game_dir, request.merged, &client_jar);
|
let classpath = build_classpath(request.game_dir, request.merged, &client_jar);
|
||||||
|
|
||||||
let mut vars: HashMap<&str, String> = HashMap::new();
|
let mut vars: HashMap<&str, String> = HashMap::new();
|
||||||
vars.insert("auth_player_name", request.identity.name().to_string());
|
let identity = request.admission.identity();
|
||||||
|
vars.insert("auth_player_name", identity.name().to_string());
|
||||||
// NeoForge's inherited JVM profile uses `${version_name}.jar` in
|
// NeoForge's inherited JVM profile uses `${version_name}.jar` in
|
||||||
// `-DignoreList`. The actual client jar belongs to the vanilla parent
|
// `-DignoreList`. The actual client jar belongs to the vanilla parent
|
||||||
// (`1.21.1.jar`), not to the child profile (`neoforge-...`), so this
|
// (`1.21.1.jar`), not to the child profile (`neoforge-...`), so this
|
||||||
@@ -205,14 +207,11 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
|||||||
vars.insert("game_directory", request.profile_dir.display().to_string());
|
vars.insert("game_directory", request.profile_dir.display().to_string());
|
||||||
vars.insert("assets_root", assets_root.display().to_string());
|
vars.insert("assets_root", assets_root.display().to_string());
|
||||||
vars.insert("assets_index_name", request.merged.asset_index.id.clone());
|
vars.insert("assets_index_name", request.merged.asset_index.id.clone());
|
||||||
vars.insert("auth_uuid", request.identity.uuid());
|
vars.insert("auth_uuid", identity.uuid());
|
||||||
vars.insert(
|
vars.insert("auth_access_token", identity.access_token().to_string());
|
||||||
"auth_access_token",
|
|
||||||
request.identity.access_token().to_string(),
|
|
||||||
);
|
|
||||||
vars.insert("clientid", launcher_client_id(request.game_dir)?);
|
vars.insert("clientid", launcher_client_id(request.game_dir)?);
|
||||||
vars.insert("auth_xuid", request.identity.xuid().to_string());
|
vars.insert("auth_xuid", identity.xuid().to_string());
|
||||||
vars.insert("user_type", request.identity.user_type().to_string());
|
vars.insert("user_type", identity.user_type().to_string());
|
||||||
vars.insert("version_type", "ShaCraft Launcher".to_string());
|
vars.insert("version_type", "ShaCraft Launcher".to_string());
|
||||||
vars.insert("natives_directory", natives_dir.display().to_string());
|
vars.insert("natives_directory", natives_dir.display().to_string());
|
||||||
vars.insert("launcher_name", "ShaCraft Launcher".to_string());
|
vars.insert("launcher_name", "ShaCraft Launcher".to_string());
|
||||||
@@ -229,6 +228,7 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
|||||||
let game_args = mojang::resolve_arguments(&request.merged.game_arguments, &no_features);
|
let game_args = mojang::resolve_arguments(&request.merged.game_arguments, &no_features);
|
||||||
|
|
||||||
let mut command = Command::new(request.java_executable);
|
let mut command = Command::new(request.java_executable);
|
||||||
|
request.admission.configure_child(&mut command);
|
||||||
let memory_argument = format!("-Xmx{}M", request.memory_mb);
|
let memory_argument = format!("-Xmx{}M", request.memory_mb);
|
||||||
if cfg!(windows) {
|
if cfg!(windows) {
|
||||||
let argfile = request.profile_dir.join(".shacraft-jvm.args");
|
let argfile = request.profile_dir.join(".shacraft-jvm.args");
|
||||||
@@ -245,25 +245,92 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
|||||||
for argument in game_args {
|
for argument in game_args {
|
||||||
command.arg(substitute(&argument, &vars));
|
command.arg(substitute(&argument, &vars));
|
||||||
}
|
}
|
||||||
command.current_dir(request.profile_dir);
|
// This endpoint is native-owned; a manifest cannot redirect game admission.
|
||||||
// Do not inherit a stale grant from the launcher process environment.
|
if request.admission.server_id() == "minigames" {
|
||||||
command.env_remove("SHACRAFT_ONBOARDING_TOKEN");
|
command.args(["--quickPlayMultiplayer", "shacraft.ru:25568"]);
|
||||||
if let Some(token) = request.onboarding_token {
|
|
||||||
command.env("SHACRAFT_ONBOARDING_TOKEN", token);
|
|
||||||
}
|
}
|
||||||
command.stdin(Stdio::null());
|
command.current_dir(request.profile_dir);
|
||||||
|
|
||||||
let log_file = fs::File::create(request.log_path)?;
|
let log_file = fs::File::create(request.log_path)?;
|
||||||
command.stdout(Stdio::from(log_file.try_clone()?));
|
command.stdout(Stdio::from(log_file.try_clone()?));
|
||||||
command.stderr(Stdio::from(log_file));
|
command.stderr(Stdio::from(log_file));
|
||||||
|
|
||||||
Ok(command.spawn()?)
|
Ok(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launch_arguments_and_written_files_never_contain_admission_secrets() {
|
||||||
|
use crate::admission::{AdmissionKey, PRIVATE_KEY_ENV, TICKET_ENV};
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
|
|
||||||
|
let directory =
|
||||||
|
std::env::temp_dir().join(format!("shacraft-launch-proof-{}", random_uuid_v4()));
|
||||||
|
let game_dir = directory.join("game");
|
||||||
|
let profile_dir = directory.join("profile");
|
||||||
|
let log_path = directory.join("game.log");
|
||||||
|
for server_id in ["aoc", "minigames"] {
|
||||||
|
let vanilla: mojang::VersionJson = serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "1.21.1",
|
||||||
|
"mainClass": "net.minecraft.client.main.Main",
|
||||||
|
"arguments": {
|
||||||
|
"game": ["--username", "${auth_player_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}"],
|
||||||
|
"jvm": ["-cp", "${classpath}", "-Dlauncher=${launcher_name}"]
|
||||||
|
},
|
||||||
|
"assetIndex": {"id": "17", "sha1": "0".repeat(40), "size": 1, "url": "https://piston-meta.mojang.com/assets"},
|
||||||
|
"downloads": {"client": {"sha1": "0".repeat(40), "size": 1, "url": "https://piston-data.mojang.com/client.jar"}}
|
||||||
|
})).unwrap();
|
||||||
|
let merged = mojang::merge_versions(&vanilla, None).unwrap();
|
||||||
|
let response = serde_json::from_value(serde_json::json!({
|
||||||
|
"ticket_id": URL_SAFE_NO_PAD.encode([73_u8; 32]), "mc_username": "Ticket_Name",
|
||||||
|
"server_id": server_id, "expires_in_seconds": 600
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let admission = AdmissionKey::generate(server_id).unwrap().bind(response).unwrap();
|
||||||
|
let request = LaunchRequest {
|
||||||
|
java_executable: Path::new("java"),
|
||||||
|
game_dir: &game_dir,
|
||||||
|
profile_dir: &profile_dir,
|
||||||
|
merged: &merged,
|
||||||
|
admission: &admission,
|
||||||
|
memory_mb: 6144,
|
||||||
|
log_path: &log_path,
|
||||||
|
};
|
||||||
|
let command = build_command(&request).unwrap();
|
||||||
|
let env: HashMap<_, _> = command.get_envs().collect();
|
||||||
|
let proof = [TICKET_ENV, PRIVATE_KEY_ENV]
|
||||||
|
.map(|name| env[std::ffi::OsStr::new(name)].unwrap().to_str().unwrap());
|
||||||
|
let arguments: Vec<_> = command
|
||||||
|
.get_args()
|
||||||
|
.map(|arg| arg.to_string_lossy())
|
||||||
|
.collect();
|
||||||
|
assert!(arguments
|
||||||
|
.windows(2)
|
||||||
|
.any(|args| args == ["--username", "Ticket_Name"]));
|
||||||
|
assert!(arguments
|
||||||
|
.windows(2)
|
||||||
|
.any(|args| args == ["--accessToken", "0"]));
|
||||||
|
assert_eq!(arguments.windows(2).any(|pair| pair == ["--quickPlayMultiplayer", "shacraft.ru:25568"]), server_id == "minigames");
|
||||||
|
for secret in proof {
|
||||||
|
assert!(arguments.iter().all(|argument| !argument.contains(secret)));
|
||||||
|
for path in [
|
||||||
|
log_path.clone(),
|
||||||
|
game_dir.join(".shacraft-client-id"),
|
||||||
|
profile_dir.join(".shacraft-jvm.args"),
|
||||||
|
] {
|
||||||
|
if path.exists() {
|
||||||
|
assert!(!fs::read_to_string(path).unwrap().contains(secret));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(command);
|
||||||
|
}
|
||||||
|
fs::remove_dir_all(directory).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generates_rfc4122_version_4_uuids() {
|
fn generates_rfc4122_version_4_uuids() {
|
||||||
let id = random_uuid_v4();
|
let id = random_uuid_v4();
|
||||||
|
|||||||
+27
-39
@@ -1,9 +1,8 @@
|
|||||||
|
mod admission;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod deb_updater;
|
||||||
mod download;
|
mod download;
|
||||||
mod update_guard;
|
mod fabric;
|
||||||
mod updater;
|
|
||||||
use tauri::Manager;
|
|
||||||
mod installation_lock;
|
|
||||||
mod inventory;
|
|
||||||
mod java;
|
mod java;
|
||||||
mod launch;
|
mod launch;
|
||||||
mod manifest;
|
mod manifest;
|
||||||
@@ -18,49 +17,37 @@ mod settings;
|
|||||||
mod shacraft_account;
|
mod shacraft_account;
|
||||||
mod storage;
|
mod storage;
|
||||||
mod trusted_http;
|
mod trusted_http;
|
||||||
|
mod updater;
|
||||||
|
|
||||||
mod commands;
|
mod commands;
|
||||||
mod operations;
|
mod operations;
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let Some(code) = deb_updater::run_helper_if_requested() {
|
||||||
|
std::process::exit(code);
|
||||||
|
}
|
||||||
|
use tauri::Manager;
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.manage(operations::LauncherOperations::default())
|
.manage(operations::LauncherOperations::default())
|
||||||
.manage(updater::UpdaterState::default())
|
.manage(updater::LauncherUpdater::default())
|
||||||
.plugin(
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
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::<operations::LauncherOperations>()
|
|
||||||
.latch_recovery(reason);
|
|
||||||
}
|
|
||||||
app.manage(instance);
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
if window
|
// Do not interrupt the short package replacement step. A
|
||||||
.app_handle()
|
// download can safely be abandoned before any file changes.
|
||||||
.state::<operations::LauncherOperations>()
|
if let Some(updater) = window.try_state::<updater::LauncherUpdater>() {
|
||||||
.lifecycle
|
if updater
|
||||||
.is_updating()
|
.state
|
||||||
{
|
.lock()
|
||||||
api.prevent_close();
|
.is_ok_and(|state| state.stage == updater::Stage::Installing)
|
||||||
|
{
|
||||||
|
api.prevent_close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.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::native_host,
|
||||||
commands::host::detect_java,
|
commands::host::detect_java,
|
||||||
commands::host::microsoft_login_available,
|
commands::host::microsoft_login_available,
|
||||||
@@ -68,22 +55,23 @@ pub fn run() {
|
|||||||
commands::profiles::inspect_remote_profile,
|
commands::profiles::inspect_remote_profile,
|
||||||
commands::profiles::sync_remote_profile,
|
commands::profiles::sync_remote_profile,
|
||||||
commands::profiles::get_server_status,
|
commands::profiles::get_server_status,
|
||||||
commands::profiles::profile_metadata,
|
|
||||||
commands::profiles::legacy_mods,
|
|
||||||
commands::profiles::backup_legacy_mods,
|
|
||||||
commands::preferences::load_settings,
|
commands::preferences::load_settings,
|
||||||
commands::preferences::save_settings,
|
commands::preferences::save_settings,
|
||||||
commands::shacraft::shacraft_authenticate,
|
commands::shacraft::shacraft_authenticate,
|
||||||
commands::shacraft::get_shacraft_account,
|
commands::shacraft::get_shacraft_account,
|
||||||
commands::shacraft::shacraft_logout,
|
commands::shacraft::shacraft_logout,
|
||||||
commands::shacraft::shacraft_start_link,
|
commands::shacraft::shacraft_start_link,
|
||||||
|
commands::shacraft::shacraft_claim_nickname,
|
||||||
commands::shacraft::shacraft_link_status,
|
commands::shacraft::shacraft_link_status,
|
||||||
commands::account::start_microsoft_login,
|
commands::account::start_microsoft_login,
|
||||||
commands::account::get_account,
|
commands::account::get_account,
|
||||||
commands::account::logout,
|
commands::account::logout,
|
||||||
commands::game::ensure_game_installed,
|
commands::game::ensure_game_installed,
|
||||||
commands::game::launch_game,
|
commands::game::launch_game,
|
||||||
commands::game::launch_onboarding
|
commands::updater::get_launcher_update_status,
|
||||||
|
commands::updater::check_launcher_update,
|
||||||
|
commands::updater::install_launcher_update,
|
||||||
|
commands::updater::restart_launcher_after_update
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running ShaCraft Launcher");
|
.expect("error while running ShaCraft Launcher");
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !is_version(&manifest.minecraft.version)
|
if !is_version(&manifest.minecraft.version)
|
||||||
|| manifest.minecraft.loader.kind.trim().is_empty()
|
|| !matches!(manifest.minecraft.loader.kind.as_str(), "neoforge" | "fabric" | "vanilla")
|
||||||
|| !is_version(&manifest.minecraft.loader.version)
|
|| !is_version(&manifest.minecraft.loader.version)
|
||||||
{
|
{
|
||||||
return Err(ManifestError::Invalid(
|
return Err(ManifestError::Invalid(
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ pub fn library_http_client() -> Result<Client, reqwest::Error> {
|
|||||||
MOJANG_HOSTS[2],
|
MOJANG_HOSTS[2],
|
||||||
MOJANG_HOSTS[3],
|
MOJANG_HOSTS[3],
|
||||||
crate::neoforge::NEOFORGE_HOST,
|
crate::neoforge::NEOFORGE_HOST,
|
||||||
|
crate::fabric::MAVEN_HOST,
|
||||||
],
|
],
|
||||||
std::time::Duration::from_secs(10 * 60),
|
std::time::Duration::from_secs(10 * 60),
|
||||||
)
|
)
|
||||||
@@ -67,7 +68,7 @@ pub fn library_http_client() -> Result<Client, reqwest::Error> {
|
|||||||
/// own fixed Maven. The profile itself comes from the SHA-256-verified
|
/// own fixed Maven. The profile itself comes from the SHA-256-verified
|
||||||
/// NeoForge installer, never from the ShaCraft manifest.
|
/// NeoForge installer, never from the ShaCraft manifest.
|
||||||
fn is_allowed_library_host(url: &str) -> bool {
|
fn is_allowed_library_host(url: &str) -> bool {
|
||||||
is_allowed_host(url) || crate::neoforge::is_allowed_host(url)
|
is_allowed_host(url) || crate::neoforge::is_allowed_host(url) || crate::trusted_http::allows(url, &[crate::fabric::MAVEN_HOST])
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
+93
-58
@@ -25,9 +25,6 @@
|
|||||||
//! arguments already present on the merged profile) and is intentionally
|
//! arguments already present on the merged profile) and is intentionally
|
||||||
//! never added to our own classpath.
|
//! never added to our own classpath.
|
||||||
|
|
||||||
#[path = "neoforge_repair.rs"]
|
|
||||||
mod repair;
|
|
||||||
|
|
||||||
use crate::download::{self, Checksum, DownloadError, ProgressCallback};
|
use crate::download::{self, Checksum, DownloadError, ProgressCallback};
|
||||||
use crate::mojang::VersionJson;
|
use crate::mojang::VersionJson;
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
@@ -59,7 +56,6 @@ pub enum NeoForgeError {
|
|||||||
Download(DownloadError),
|
Download(DownloadError),
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
InvalidJson(serde_json::Error),
|
InvalidJson(serde_json::Error),
|
||||||
InvalidInstallation(String),
|
|
||||||
InstallerFailed {
|
InstallerFailed {
|
||||||
exit_code: Option<i32>,
|
exit_code: Option<i32>,
|
||||||
output_tail: String,
|
output_tail: String,
|
||||||
@@ -80,9 +76,6 @@ impl fmt::Display for NeoForgeError {
|
|||||||
Self::Download(error) => write!(formatter, "{error}"),
|
Self::Download(error) => write!(formatter, "{error}"),
|
||||||
Self::Io(error) => write!(formatter, "I/O error: {error}"),
|
Self::Io(error) => write!(formatter, "I/O error: {error}"),
|
||||||
Self::InvalidJson(error) => write!(formatter, "invalid NeoForge version JSON: {error}"),
|
Self::InvalidJson(error) => write!(formatter, "invalid NeoForge version JSON: {error}"),
|
||||||
Self::InvalidInstallation(message) => {
|
|
||||||
write!(formatter, "invalid NeoForge installation: {message}")
|
|
||||||
}
|
|
||||||
Self::InstallerFailed {
|
Self::InstallerFailed {
|
||||||
exit_code,
|
exit_code,
|
||||||
output_tail,
|
output_tail,
|
||||||
@@ -173,8 +166,25 @@ pub fn installed_version_json_path(game_dir: &Path, loader_version: &str) -> Pat
|
|||||||
.join(format!("neoforge-{loader_version}.json"))
|
.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
|
/// The installer jar bundles its own `install_profile.json`, which lists
|
||||||
/// which libraries it may download and which processors it may
|
/// exactly which libraries it will download and which processors it will
|
||||||
/// run to patch the client — the same manifest the installer itself reads.
|
/// run to patch the client — the same manifest the installer itself reads.
|
||||||
/// Reading it upfront gives a real, version-agnostic total for progress
|
/// Reading it upfront gives a real, version-agnostic total for progress
|
||||||
/// reporting instead of a guessed constant.
|
/// reporting instead of a guessed constant.
|
||||||
@@ -306,51 +316,66 @@ fn run_installer_with_progress(
|
|||||||
Ok((status.code(), tail))
|
Ok((status.code(), tail))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verifies a generated installation against its provenance receipt. Legacy
|
/// Ensures NeoForge `loader_version` is installed into the shared
|
||||||
/// installations and corrupt outputs are rebuilt by the verified official
|
/// `game_dir` (vanilla libraries/version must already be there so the
|
||||||
/// installer in an empty staging directory. The caller must ensure vanilla's
|
/// installer can reuse them). No-op if already installed. Runs the
|
||||||
/// client JAR first; the staged copy is checked against `vanilla` again before
|
/// installer headlessly with `java_executable`; its own network calls go
|
||||||
/// any processor runs. No existing generated artifacts are adopted as trusted.
|
/// 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.
|
||||||
pub fn ensure_client_installed(
|
pub fn ensure_client_installed(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
java_executable: &Path,
|
java_executable: &Path,
|
||||||
game_dir: &Path,
|
game_dir: &Path,
|
||||||
cache_dir: &Path,
|
cache_dir: &Path,
|
||||||
loader_version: &str,
|
loader_version: &str,
|
||||||
vanilla: &VersionJson,
|
|
||||||
on_progress: &ProgressCallback,
|
on_progress: &ProgressCallback,
|
||||||
) -> Result<VersionJson, NeoForgeError> {
|
) -> Result<VersionJson, NeoForgeError> {
|
||||||
let installer_path = ensure_installer(client, cache_dir, loader_version)?;
|
let version_json_path = installed_version_json_path(game_dir, loader_version);
|
||||||
let mut rebuilt = false;
|
if !installation_complete(game_dir, loader_version) {
|
||||||
let version = repair::ensure(
|
ensure_launcher_profiles_stub(game_dir)?;
|
||||||
&installer_path,
|
let installer_path = ensure_installer(client, cache_dir, loader_version)?;
|
||||||
game_dir,
|
|
||||||
cache_dir,
|
// A leftover version JSON makes some installer versions treat the
|
||||||
loader_version,
|
// profile as already installed even when the patched client was
|
||||||
vanilla,
|
// deleted or quarantined. Remove only that generated marker so the
|
||||||
|stage| {
|
// official installer is forced to rebuild the incomplete profile.
|
||||||
rebuilt = true;
|
match fs::remove_file(&version_json_path) {
|
||||||
let (total_libraries, total_processors) =
|
Ok(()) => {}
|
||||||
read_install_profile_counts(&installer_path).unwrap_or((0, 0));
|
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||||
let total = (total_libraries + total_processors).max(1);
|
Err(error) => return Err(NeoForgeError::Io(error)),
|
||||||
on_progress(0, total);
|
}
|
||||||
run_installer_with_progress(
|
|
||||||
java_executable,
|
let (total_libraries, total_processors) =
|
||||||
&installer_path,
|
read_install_profile_counts(&installer_path).unwrap_or((0, 0));
|
||||||
stage,
|
let total = (total_libraries + total_processors).max(1);
|
||||||
cache_dir,
|
on_progress(0, total);
|
||||||
total_libraries,
|
|
||||||
total,
|
let (exit_code, tail) = run_installer_with_progress(
|
||||||
on_progress,
|
java_executable,
|
||||||
)?;
|
&installer_path,
|
||||||
on_progress(total, total);
|
game_dir,
|
||||||
Ok(())
|
cache_dir,
|
||||||
},
|
total_libraries,
|
||||||
)?;
|
total,
|
||||||
if !rebuilt {
|
on_progress,
|
||||||
|
)?;
|
||||||
|
if !installation_complete(game_dir, loader_version) {
|
||||||
|
return Err(NeoForgeError::InstallerFailed {
|
||||||
|
exit_code,
|
||||||
|
output_tail: tail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
on_progress(total, total);
|
||||||
|
} else {
|
||||||
on_progress(1, 1);
|
on_progress(1, 1);
|
||||||
}
|
}
|
||||||
Ok(version)
|
|
||||||
|
let bytes = fs::read(&version_json_path)?;
|
||||||
|
serde_json::from_slice(&bytes).map_err(NeoForgeError::InvalidJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -387,6 +412,25 @@ mod tests {
|
|||||||
fs::remove_dir_all(&dir).unwrap();
|
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]
|
#[test]
|
||||||
fn observe_installer_line_counts_downloads_and_processor_headers() {
|
fn observe_installer_line_counts_downloads_and_processor_headers() {
|
||||||
let downloads_done = AtomicU64::new(0);
|
let downloads_done = AtomicU64::new(0);
|
||||||
@@ -448,7 +492,8 @@ mod tests {
|
|||||||
|
|
||||||
/// Full live pipeline: provisions a real Java 21 (runtime.rs) if none
|
/// Full live pipeline: provisions a real Java 21 (runtime.rs) if none
|
||||||
/// is already usable, then runs the real NeoForge 21.1.248 installer
|
/// is already usable, then runs the real NeoForge 21.1.248 installer
|
||||||
/// into a staging game dir with a verified vanilla 1.21.1 input and checks the
|
/// into an empty game dir (it fetches and patches vanilla 1.21.1
|
||||||
|
/// itself — confirmed manually, no pre-seeding needed) and checks the
|
||||||
/// installed profile merges into a launch-shaped spec together with a
|
/// installed profile merges into a launch-shaped spec together with a
|
||||||
/// separately-fetched vanilla version JSON (mojang.rs), exactly as
|
/// separately-fetched vanilla version JSON (mojang.rs), exactly as
|
||||||
/// `lib.rs`'s `ensure_game_installed` command will do it. Not run by
|
/// `lib.rs`'s `ensure_game_installed` command will do it. Not run by
|
||||||
@@ -459,10 +504,8 @@ mod tests {
|
|||||||
use crate::{java, mojang};
|
use crate::{java, mojang};
|
||||||
|
|
||||||
let client = Client::builder().build().unwrap();
|
let client = Client::builder().build().unwrap();
|
||||||
let root = std::env::temp_dir()
|
let root =
|
||||||
.canonicalize()
|
std::env::temp_dir().join(format!("shacraft-neoforge-pipeline-{}", std::process::id()));
|
||||||
.unwrap()
|
|
||||||
.join(format!("shacraft-neoforge-pipeline-{}", std::process::id()));
|
|
||||||
let game_dir = root.join("game");
|
let game_dir = root.join("game");
|
||||||
let cache_dir = root.join("cache");
|
let cache_dir = root.join("cache");
|
||||||
fs::create_dir_all(&cache_dir).unwrap();
|
fs::create_dir_all(&cache_dir).unwrap();
|
||||||
@@ -475,14 +518,8 @@ mod tests {
|
|||||||
let java_install =
|
let java_install =
|
||||||
java::ensure_java(&client, &root.join("runtime"), 21, &no_progress).unwrap();
|
java::ensure_java(&client, &root.join("runtime"), 21, &no_progress).unwrap();
|
||||||
|
|
||||||
// Verify vanilla before the installer is allowed to use it.
|
// The installer fetches and patches vanilla itself; we don't
|
||||||
mojang::ensure_client_jar(
|
// pre-download it. It only needs a Java runtime and an empty dir.
|
||||||
&client,
|
|
||||||
&game_dir,
|
|
||||||
&vanilla.id,
|
|
||||||
&vanilla.downloads.as_ref().unwrap().client,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let progress_calls: Arc<Mutex<Vec<(u64, u64)>>> = Arc::new(Mutex::new(Vec::new()));
|
let progress_calls: Arc<Mutex<Vec<(u64, u64)>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
let progress: ProgressCallback = {
|
let progress: ProgressCallback = {
|
||||||
let progress_calls = Arc::clone(&progress_calls);
|
let progress_calls = Arc::clone(&progress_calls);
|
||||||
@@ -494,7 +531,6 @@ mod tests {
|
|||||||
&game_dir,
|
&game_dir,
|
||||||
&cache_dir,
|
&cache_dir,
|
||||||
"21.1.248",
|
"21.1.248",
|
||||||
&vanilla,
|
|
||||||
&progress,
|
&progress,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -541,7 +577,6 @@ mod tests {
|
|||||||
&game_dir,
|
&game_dir,
|
||||||
&cache_dir,
|
&cache_dir,
|
||||||
"21.1.248",
|
"21.1.248",
|
||||||
&vanilla,
|
|
||||||
&no_progress,
|
&no_progress,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -1,765 +0,0 @@
|
|||||||
//! 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<String>) -> 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<PathBuf, NeoForgeError> {
|
|
||||||
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<Vec<u8>, 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<fs::File>, name: &str) -> Result<Vec<u8>, 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<String, NeoForgeError> {
|
|
||||||
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<u8>,
|
|
||||||
version_relative: String,
|
|
||||||
outputs: BTreeMap<String, Option<String>>,
|
|
||||||
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<String, FileDigest>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct FileDigest {
|
|
||||||
size: u64,
|
|
||||||
sha256: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Recipe {
|
|
||||||
fn read(installer: &Path, loader: &str, vanilla: &VersionJson) -> Result<Self, NeoForgeError> {
|
|
||||||
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::<VersionJson>(&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<Item = &String> {
|
|
||||||
self.outputs
|
|
||||||
.keys()
|
|
||||||
.chain(std::iter::once(&self.version_relative))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current(&self, root: &Path, receipt_path: &Path) -> Result<bool, NeoForgeError> {
|
|
||||||
let receipt = match bounded_read(receipt_path) {
|
|
||||||
Ok(bytes) => match serde_json::from_slice::<Receipt>(&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<Self, NeoForgeError> {
|
|
||||||
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<FileDigest, NeoForgeError> {
|
|
||||||
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<VersionJson, NeoForgeError> {
|
|
||||||
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<u8> {
|
|
||||||
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<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Fixture {
|
|
||||||
fn new() -> Self {
|
|
||||||
// macOS temporary directories may use the system /var -> /private/var
|
|
||||||
// alias. Canonicalize only this trusted fixture anchor; production
|
|
||||||
// safe_path must continue rejecting root/descendant substitutions.
|
|
||||||
let temporary_root = std::env::temp_dir().canonicalize().unwrap();
|
|
||||||
let root = Stage::new(&temporary_root).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<VersionJson, NeoForgeError> {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+69
-60
@@ -3,17 +3,46 @@
|
|||||||
//! Acquire before scheduling the worker and move the permit into it. Dropping
|
//! Acquire before scheduling the worker and move the permit into it. Dropping
|
||||||
//! the caller's future cannot unlock an operation that is still running.
|
//! the caller's future cannot unlock an operation that is still running.
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
Arc, Mutex,
|
Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Clone, Default)]
|
||||||
pub(crate) struct LauncherOperations {
|
pub(crate) struct LauncherOperations {
|
||||||
pub lifecycle: Lifecycle,
|
|
||||||
recovery: Mutex<Option<String>>,
|
|
||||||
pub installation: Operation,
|
pub installation: Operation,
|
||||||
pub account: Operation,
|
pub account: Operation,
|
||||||
pub shacraft_account: Operation,
|
pub shacraft_account: Operation,
|
||||||
|
// Held from launch scheduling until Child::wait completes, not just spawn.
|
||||||
|
pub game: Operation,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LauncherOperations {
|
||||||
|
/// Acquire every mutation gate without waiting. Partial acquisition is
|
||||||
|
/// rolled back by RAII, so a failed update cannot strand an account gate.
|
||||||
|
pub fn acquire_update(&self) -> Result<UpdatePermits, String> {
|
||||||
|
let installation = self.installation.acquire("Установка игры")?;
|
||||||
|
let account = self.account.acquire("Вход в аккаунт")?;
|
||||||
|
let shacraft_account = self
|
||||||
|
.shacraft_account
|
||||||
|
.acquire("Операция с аккаунтом ShaCraft")?;
|
||||||
|
let game = self
|
||||||
|
.game
|
||||||
|
.acquire("Игра")
|
||||||
|
.map_err(|_| "Закройте Minecraft перед обновлением лаунчера.".to_string())?;
|
||||||
|
Ok(UpdatePermits {
|
||||||
|
_installation: installation,
|
||||||
|
_account: account,
|
||||||
|
_shacraft_account: shacraft_account,
|
||||||
|
_game: game,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct UpdatePermits {
|
||||||
|
_installation: Permit,
|
||||||
|
_account: Permit,
|
||||||
|
_shacraft_account: Permit,
|
||||||
|
_game: Permit,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
@@ -38,7 +67,7 @@ impl Drop for Permit {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::Operation;
|
use super::{LauncherOperations, Operation};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_overlap_and_releases_on_worker_error() {
|
fn rejects_overlap_and_releases_on_worker_error() {
|
||||||
@@ -51,63 +80,43 @@ mod tests {
|
|||||||
assert!(worker().is_err());
|
assert!(worker().is_err());
|
||||||
assert!(operation.acquire("Installation").is_ok());
|
assert!(operation.acquire("Installation").is_ok());
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Owned, Send permits are held by the worker, not the awaiting IPC future.
|
#[test]
|
||||||
// Readers represent all native operations which can persist or launch; the
|
fn running_game_blocks_update_and_partial_locks_are_released() {
|
||||||
// sole writer represents launcher replacement, including its download stage.
|
let operations = LauncherOperations::default();
|
||||||
#[derive(Default)]
|
let game = operations.game.acquire("game").unwrap();
|
||||||
pub(crate) struct Lifecycle(Arc<AtomicUsize>);
|
assert!(operations.acquire_update().is_err());
|
||||||
const EXCLUSIVE: usize = usize::MAX;
|
assert!(operations.installation.acquire("install").is_ok());
|
||||||
impl Lifecycle {
|
assert!(operations.account.acquire("account").is_ok());
|
||||||
pub fn shared(&self) -> Result<SharedPermit, String> {
|
assert!(operations
|
||||||
self.0
|
.shacraft_account
|
||||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| {
|
.acquire("ShaCraft account")
|
||||||
if n < EXCLUSIVE - 1 {
|
.is_ok());
|
||||||
Some(n + 1)
|
drop(game);
|
||||||
} else {
|
assert!(operations.acquire_update().is_ok());
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map_err(|_| "Лаунчер обновляется. Дождитесь завершения и перезапуска.".to_string())?;
|
|
||||||
Ok(SharedPermit(self.0.clone()))
|
|
||||||
}
|
}
|
||||||
pub fn exclusive(&self) -> Result<ExclusivePermit, String> {
|
|
||||||
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<AtomicUsize>);
|
|
||||||
impl Drop for SharedPermit {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.0.fetch_sub(1, Ordering::Release);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub(crate) struct ExclusivePermit(Arc<AtomicUsize>);
|
|
||||||
impl Drop for ExclusivePermit {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.0.store(0, Ordering::Release);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LauncherOperations {
|
#[test]
|
||||||
/// Startup recovery is latched for this process. Removing a marker in a
|
fn update_excludes_game_and_accounts_until_permit_drop() {
|
||||||
/// running application is never authority to resume writes or launch.
|
let operations = LauncherOperations::default();
|
||||||
pub fn latch_recovery(&self, reason: String) {
|
let permit = operations.acquire_update().unwrap();
|
||||||
*self.recovery.lock().unwrap() = Some(reason);
|
assert!(operations.installation.acquire("install").is_err());
|
||||||
|
assert!(operations.account.acquire("account").is_err());
|
||||||
|
assert!(operations
|
||||||
|
.shacraft_account
|
||||||
|
.acquire("ShaCraft account")
|
||||||
|
.is_err());
|
||||||
|
assert!(operations.game.acquire("game").is_err());
|
||||||
|
assert!(operations.acquire_update().is_err());
|
||||||
|
drop(permit);
|
||||||
|
assert!(operations.acquire_update().is_ok());
|
||||||
}
|
}
|
||||||
pub fn ensure_writable(&self) -> Result<(), String> {
|
|
||||||
match self.recovery.lock().unwrap().as_ref() {
|
#[test]
|
||||||
Some(reason) => Err(reason.clone()),
|
fn account_operation_blocks_update_without_stranding_installation() {
|
||||||
None => Ok(()),
|
let operations = LauncherOperations::default();
|
||||||
}
|
let _account = operations.shacraft_account.acquire("account").unwrap();
|
||||||
|
assert!(operations.acquire_update().is_err());
|
||||||
|
assert!(operations.installation.acquire("install").is_ok());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-724
@@ -1,12 +1,8 @@
|
|||||||
use crate::download::{self, Checksum, DownloadError};
|
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 crate::manifest::{is_allowed_download_url, FilePolicy, ManagedFile, Manifest};
|
||||||
use reqwest::{blocking::Client, redirect::Policy};
|
use reqwest::{blocking::Client, redirect::Policy};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Serialize;
|
||||||
#[cfg(test)]
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeSet, HashSet},
|
|
||||||
fmt, fs, io,
|
fmt, fs, io,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
time::Duration,
|
time::Duration,
|
||||||
@@ -19,10 +15,6 @@ pub struct ProfileInspection {
|
|||||||
pub managed_files: usize,
|
pub managed_files: usize,
|
||||||
pub missing_files: usize,
|
pub missing_files: usize,
|
||||||
pub mismatched_files: usize,
|
pub mismatched_files: usize,
|
||||||
pub stale_files: usize,
|
|
||||||
pub conflicts: Vec<String>,
|
|
||||||
pub pending_update: bool,
|
|
||||||
pub legacy_files: usize,
|
|
||||||
pub up_to_date: bool,
|
pub up_to_date: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +25,6 @@ pub struct SyncResult {
|
|||||||
pub downloaded_files: usize,
|
pub downloaded_files: usize,
|
||||||
pub reused_files: usize,
|
pub reused_files: usize,
|
||||||
pub downloaded_bytes: u64,
|
pub downloaded_bytes: u64,
|
||||||
pub removed_files: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -42,121 +33,54 @@ pub enum ProfileError {
|
|||||||
Network(reqwest::Error),
|
Network(reqwest::Error),
|
||||||
Download { path: String, source: DownloadError },
|
Download { path: String, source: DownloadError },
|
||||||
UnsafePath(PathBuf),
|
UnsafePath(PathBuf),
|
||||||
Conflict(Vec<String>),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for ProfileError {
|
impl fmt::Display for ProfileError {
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::Io(error) => write!(formatter, "Cannot access profile: {error}"),
|
Self::Io(error) => write!(formatter, "Cannot access profile: {error}"),
|
||||||
Self::Network(error) => write!(formatter, "Cannot download profile file: {error}"),
|
Self::Network(error) => write!(formatter, "Cannot download profile file: {error}"),
|
||||||
Self::Download { path, source } => write!(formatter, "Download failed for {path}: {source}"),
|
Self::Download { path, source } => {
|
||||||
Self::UnsafePath(path) => write!(formatter, "Unsafe or protected profile path: {}", path.display()),
|
write!(formatter, "Download failed for {path}: {source}")
|
||||||
Self::Conflict(paths) => write!(formatter, "Файлы изменены или не принадлежат лаунчеру; сохранены без изменений: {}. Проверьте список пользовательских модов.", paths.join(", ")),
|
}
|
||||||
|
Self::UnsafePath(path) => write!(
|
||||||
|
formatter,
|
||||||
|
"Profile path contains a symbolic link: {}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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<Option<Fingerprint>, ProfileError> {
|
|
||||||
inventory::fingerprint(&managed_target(root, relative)?).map_err(ProfileError::Io)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn inspect(root: &Path, manifest: &Manifest) -> Result<ProfileInspection, ProfileError> {
|
pub fn inspect(root: &Path, manifest: &Manifest) -> Result<ProfileInspection, ProfileError> {
|
||||||
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 missing_files = 0;
|
||||||
let mut mismatched_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 {
|
for expected in &manifest.files {
|
||||||
let actual = current(root, &expected.path)?;
|
let path = managed_target(root, &expected.path)?;
|
||||||
if actual.is_none() {
|
if !path.try_exists().map_err(ProfileError::Io)? {
|
||||||
missing_files += 1;
|
missing_files += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if expected.policy == FilePolicy::Seed {
|
if matches!(expected.policy, FilePolicy::Seed) && path.is_file() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if actual.as_ref() != Some(&expected_fingerprint(expected)) {
|
let checksum = Checksum::Sha256(expected.sha256.clone());
|
||||||
|
if !download::is_current(&path, Some(expected.size), &checksum).map_err(ProfileError::Io)? {
|
||||||
mismatched_files += 1;
|
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 {
|
Ok(ProfileInspection {
|
||||||
root: root.display().to_string(),
|
root: root.display().to_string(),
|
||||||
managed_files: manifest.files.len(),
|
managed_files: manifest.files.len(),
|
||||||
missing_files,
|
missing_files,
|
||||||
mismatched_files,
|
mismatched_files,
|
||||||
stale_files,
|
up_to_date: missing_files == 0 && mismatched_files == 0,
|
||||||
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_snapshot(
|
pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError> {
|
||||||
root: &Path,
|
|
||||||
snapshot: &crate::remote::VerifiedSnapshot,
|
|
||||||
) -> Result<SyncResult, ProfileError> {
|
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.connect_timeout(Duration::from_secs(15))
|
.connect_timeout(Duration::from_secs(15))
|
||||||
.timeout(Duration::from_secs(10 * 60))
|
.timeout(Duration::from_secs(10 * 60))
|
||||||
@@ -171,309 +95,64 @@ pub fn sync_snapshot(
|
|||||||
}))
|
}))
|
||||||
.build()
|
.build()
|
||||||
.map_err(ProfileError::Network)?;
|
.map_err(ProfileError::Network)?;
|
||||||
sync_with_snapshot(root, &snapshot.manifest, &snapshot.digest, |file, stage| {
|
|
||||||
download_managed_file(&client, file, stage)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
let mut downloaded_files = 0;
|
||||||
fn sync_with(
|
|
||||||
root: &Path,
|
|
||||||
manifest: &Manifest,
|
|
||||||
download: impl FnMut(&ManagedFile, &Path) -> Result<u64, ProfileError>,
|
|
||||||
) -> Result<SyncResult, ProfileError> {
|
|
||||||
sync_with_snapshot(root, manifest, &manifest_snapshot(manifest), download)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_with_snapshot(
|
|
||||||
root: &Path,
|
|
||||||
manifest: &Manifest,
|
|
||||||
snapshot: &str,
|
|
||||||
mut download: impl FnMut(&ManagedFile, &Path) -> Result<u64, ProfileError>,
|
|
||||||
) -> Result<SyncResult, ProfileError> {
|
|
||||||
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 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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;
|
let mut downloaded_bytes = 0;
|
||||||
for (index, file) in &downloads {
|
|
||||||
let stage = store
|
for expected in &manifest.files {
|
||||||
.stage(&transaction, *index)
|
let target = managed_target(root, &expected.path)?;
|
||||||
.map_err(ProfileError::Io)?;
|
if matches!(expected.policy, FilePolicy::Seed) && target.is_file() {
|
||||||
downloaded_bytes += download(file, &stage)?;
|
reused_files += 1;
|
||||||
if inventory::fingerprint(&stage)
|
continue;
|
||||||
.map_err(ProfileError::Io)?
|
|
||||||
.as_ref()
|
|
||||||
!= Some(&expected_fingerprint(file))
|
|
||||||
{
|
|
||||||
return Err(ProfileError::Io(inventory::invalid(
|
|
||||||
"Staged profile file failed verification",
|
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
let checksum = Checksum::Sha256(expected.sha256.clone());
|
||||||
|
if download::is_current(&target, Some(expected.size), &checksum)
|
||||||
|
.map_err(ProfileError::Io)?
|
||||||
|
{
|
||||||
|
reused_files += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = download_managed_file(&client, expected, &target)?;
|
||||||
|
downloaded_files += 1;
|
||||||
|
downloaded_bytes += bytes;
|
||||||
}
|
}
|
||||||
// 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 {
|
Ok(SyncResult {
|
||||||
root: root.display().to_string(),
|
root: root.display().to_string(),
|
||||||
downloaded_files: downloads.len(),
|
downloaded_files,
|
||||||
reused_files,
|
reused_files,
|
||||||
downloaded_bytes,
|
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<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn legacy_mods(
|
|
||||||
root: &Path,
|
|
||||||
manifest: &Manifest,
|
|
||||||
owned: &Inventory,
|
|
||||||
) -> Result<Vec<LegacyMod>, 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<Vec<LegacyMod>, 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<LegacyBackup, ProfileError> {
|
|
||||||
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::<Vec<_>>())
|
|
||||||
.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(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reject pre-existing links in the managed subtree before inspecting or
|
||||||
|
/// replacing files. A signed relative path must not follow a local link into
|
||||||
|
/// an unrelated directory. This is not a sandbox against a hostile local user
|
||||||
|
/// changing directories concurrently under the launcher's OS identity.
|
||||||
fn managed_target(root: &Path, relative: &str) -> Result<PathBuf, ProfileError> {
|
fn managed_target(root: &Path, relative: &str) -> Result<PathBuf, ProfileError> {
|
||||||
if inventory::protected(relative) {
|
let mut path = root.to_path_buf();
|
||||||
return Err(ProfileError::UnsafePath(root.join(relative)));
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()))
|
||||||
|
}
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(error) => Err(ProfileError::Io(error)),
|
||||||
}
|
}
|
||||||
inventory::checked_path(root, relative)
|
|
||||||
.map_err(|_| ProfileError::UnsafePath(root.join(relative)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn download_managed_file(
|
fn download_managed_file(
|
||||||
@@ -481,17 +160,18 @@ fn download_managed_file(
|
|||||||
expected: &ManagedFile,
|
expected: &ManagedFile,
|
||||||
target: &Path,
|
target: &Path,
|
||||||
) -> Result<u64, ProfileError> {
|
) -> Result<u64, ProfileError> {
|
||||||
|
let checksum = Checksum::Sha256(expected.sha256.clone());
|
||||||
download::download_verified(
|
download::download_verified(
|
||||||
client,
|
client,
|
||||||
&expected.url,
|
&expected.url,
|
||||||
target,
|
target,
|
||||||
Some(expected.size),
|
Some(expected.size),
|
||||||
&Checksum::Sha256(expected.sha256.clone()),
|
&checksum,
|
||||||
|_, _| {},
|
|_, _| {},
|
||||||
)
|
)
|
||||||
.map_err(|source| ProfileError::Download {
|
.map_err(|error| ProfileError::Download {
|
||||||
path: expected.path.clone(),
|
path: expected.path.clone(),
|
||||||
source,
|
source: error,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,17 +179,45 @@ fn download_managed_file(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::inspect;
|
use super::inspect;
|
||||||
use crate::manifest::{FilePolicy, Loader, ManagedFile, Manifest, Minecraft};
|
use crate::manifest::{FilePolicy, Loader, ManagedFile, Manifest, Minecraft};
|
||||||
#[cfg(test)]
|
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::{
|
use std::{
|
||||||
fs, process,
|
fs, process,
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
fn trusted_temporary_root() -> std::path::PathBuf {
|
#[test]
|
||||||
// Resolve only the OS-provided fixture anchor. The profile root, its
|
#[ignore = "downloads the public signed admission mod into a temporary directory"]
|
||||||
// parent and payload descendants retain their production link checks.
|
fn live_admission_mod_is_restored_when_missing_or_corrupt() {
|
||||||
std::env::temp_dir().canonicalize().unwrap()
|
let mut manifest = crate::remote::fetch_manifest("aeronautics").unwrap();
|
||||||
|
manifest.files.retain(|file| {
|
||||||
|
file.path.starts_with("mods/shacraft-admission") && file.path.ends_with(".jar")
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
manifest.files.len(),
|
||||||
|
1,
|
||||||
|
"exactly one admission mod is required"
|
||||||
|
);
|
||||||
|
assert!(matches!(manifest.files[0].policy, FilePolicy::Managed));
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"shacraft-live-admission-{}-{}",
|
||||||
|
process::id(),
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
assert_eq!(inspect(&root, &manifest).unwrap().missing_files, 1);
|
||||||
|
super::sync(&root, &manifest).unwrap();
|
||||||
|
assert!(inspect(&root, &manifest).unwrap().up_to_date);
|
||||||
|
let target = root.join(&manifest.files[0].path);
|
||||||
|
fs::write(&target, b"corrupt fixture").unwrap();
|
||||||
|
assert!(!inspect(&root, &manifest).unwrap().up_to_date);
|
||||||
|
super::sync(&root, &manifest).unwrap();
|
||||||
|
assert!(inspect(&root, &manifest).unwrap().up_to_date);
|
||||||
|
fs::remove_file(&target).unwrap();
|
||||||
|
super::sync(&root, &manifest).unwrap();
|
||||||
|
assert!(inspect(&root, &manifest).unwrap().up_to_date);
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn manifest(hash: String, size: u64) -> Manifest {
|
fn manifest(hash: String, size: u64) -> Manifest {
|
||||||
@@ -537,7 +245,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reports_missing_and_matching_files() {
|
fn reports_missing_and_matching_files() {
|
||||||
let root = trusted_temporary_root().join(format!(
|
let root = std::env::temp_dir().join(format!(
|
||||||
"shacraft-launcher-test-{}-{}",
|
"shacraft-launcher-test-{}-{}",
|
||||||
process::id(),
|
process::id(),
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
@@ -565,7 +273,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn edited_seed_files_remain_up_to_date() {
|
fn edited_seed_files_remain_up_to_date() {
|
||||||
let root = trusted_temporary_root().join(format!(
|
let root = std::env::temp_dir().join(format!(
|
||||||
"shacraft-seed-test-{}-{}",
|
"shacraft-seed-test-{}-{}",
|
||||||
process::id(),
|
process::id(),
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
@@ -583,7 +291,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preserves_changed_seed_files_as_current() {
|
fn preserves_changed_seed_files_as_current() {
|
||||||
let root = trusted_temporary_root().join(format!(
|
let root = std::env::temp_dir().join(format!(
|
||||||
"shacraft-launcher-seed-test-{}-{}",
|
"shacraft-launcher-seed-test-{}-{}",
|
||||||
process::id(),
|
process::id(),
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
@@ -606,7 +314,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn refuses_linked_profile_directories() {
|
fn refuses_linked_profile_directories() {
|
||||||
use std::os::unix::fs::symlink;
|
use std::os::unix::fs::symlink;
|
||||||
let root = trusted_temporary_root().join(format!(
|
let root = std::env::temp_dir().join(format!(
|
||||||
"shacraft-link-test-{}-{}",
|
"shacraft-link-test-{}-{}",
|
||||||
process::id(),
|
process::id(),
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
@@ -624,331 +332,3 @@ mod tests {
|
|||||||
fs::remove_dir_all(root).unwrap();
|
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<u8> {
|
|
||||||
fs::read(self.root.join(path)).unwrap()
|
|
||||||
}
|
|
||||||
fn sync(
|
|
||||||
&self,
|
|
||||||
manifest: &Manifest,
|
|
||||||
files: &[(&str, &[u8])],
|
|
||||||
) -> Result<SyncResult, ProfileError> {
|
|
||||||
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<_>>(),
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+6
-22
@@ -4,7 +4,6 @@ use ed25519_dalek::{Signature, VerifyingKey};
|
|||||||
use reqwest::header::ACCEPT_ENCODING;
|
use reqwest::header::ACCEPT_ENCODING;
|
||||||
use reqwest::{blocking::Client, redirect::Policy};
|
use reqwest::{blocking::Client, redirect::Policy};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use std::{
|
use std::{
|
||||||
fmt,
|
fmt,
|
||||||
io::{self, Read},
|
io::{self, Read},
|
||||||
@@ -14,6 +13,8 @@ use std::{
|
|||||||
|
|
||||||
const AERONAUTICS_MANIFEST: &str =
|
const AERONAUTICS_MANIFEST: &str =
|
||||||
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
|
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
|
||||||
|
const MINIGAMES_MANIFEST: &str = "https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest";
|
||||||
|
const MINIGAMES_ONLINE: &str = "https://shacraft.ru/api/online/minigames";
|
||||||
const AERONAUTICS_ONLINE: &str = "https://shacraft.ru/api/online/aoc";
|
const AERONAUTICS_ONLINE: &str = "https://shacraft.ru/api/online/aoc";
|
||||||
const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ=";
|
const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ=";
|
||||||
const MANIFEST_KEY_ID: &str = "2026-09-06";
|
const MANIFEST_KEY_ID: &str = "2026-09-06";
|
||||||
@@ -69,18 +70,10 @@ impl fmt::Display for RemoteError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct VerifiedSnapshot {
|
|
||||||
pub manifest: Manifest,
|
|
||||||
pub digest: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
|
pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
|
||||||
Ok(fetch_snapshot(profile_id)?.manifest)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn fetch_snapshot(profile_id: &str) -> Result<VerifiedSnapshot, RemoteError> {
|
|
||||||
let url = match profile_id {
|
let url = match profile_id {
|
||||||
"aeronautics" => AERONAUTICS_MANIFEST,
|
"aeronautics" => AERONAUTICS_MANIFEST,
|
||||||
|
"minigames" => MINIGAMES_MANIFEST,
|
||||||
_ => return Err(RemoteError::UnknownProfile),
|
_ => return Err(RemoteError::UnknownProfile),
|
||||||
};
|
};
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
@@ -100,7 +93,7 @@ pub fn fetch_snapshot(profile_id: &str) -> Result<VerifiedSnapshot, RemoteError>
|
|||||||
.expect("embedded public key must be 32 bytes"),
|
.expect("embedded public key must be 32 bytes"),
|
||||||
)
|
)
|
||||||
.expect("embedded public key must be valid");
|
.expect("embedded public key must be valid");
|
||||||
verify_snapshot(&source, profile_id, &public_key)
|
verify_envelope(&source, profile_id, &public_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_manifest_bytes(client: &Client, url: &str) -> Result<Vec<u8>, RemoteError> {
|
fn fetch_manifest_bytes(client: &Client, url: &str) -> Result<Vec<u8>, RemoteError> {
|
||||||
@@ -135,6 +128,7 @@ fn fetch_manifest_bytes(client: &Client, url: &str) -> Result<Vec<u8>, RemoteErr
|
|||||||
pub fn fetch_server_status(profile_id: &str) -> Result<ServerStatus, RemoteError> {
|
pub fn fetch_server_status(profile_id: &str) -> Result<ServerStatus, RemoteError> {
|
||||||
let url = match profile_id {
|
let url = match profile_id {
|
||||||
"aeronautics" => AERONAUTICS_ONLINE,
|
"aeronautics" => AERONAUTICS_ONLINE,
|
||||||
|
"minigames" => MINIGAMES_ONLINE,
|
||||||
_ => return Err(RemoteError::UnknownProfile),
|
_ => return Err(RemoteError::UnknownProfile),
|
||||||
};
|
};
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
@@ -164,20 +158,11 @@ fn read_envelope(source: impl Read) -> Result<Vec<u8>, RemoteError> {
|
|||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn verify_envelope(
|
fn verify_envelope(
|
||||||
source: &[u8],
|
source: &[u8],
|
||||||
profile_id: &str,
|
profile_id: &str,
|
||||||
public_key: &VerifyingKey,
|
public_key: &VerifyingKey,
|
||||||
) -> Result<Manifest, RemoteError> {
|
) -> Result<Manifest, RemoteError> {
|
||||||
Ok(verify_snapshot(source, profile_id, public_key)?.manifest)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_snapshot(
|
|
||||||
source: &[u8],
|
|
||||||
profile_id: &str,
|
|
||||||
public_key: &VerifyingKey,
|
|
||||||
) -> Result<VerifiedSnapshot, RemoteError> {
|
|
||||||
if source.len() > MAX_ENVELOPE_BYTES {
|
if source.len() > MAX_ENVELOPE_BYTES {
|
||||||
return Err(RemoteError::TooLarge);
|
return Err(RemoteError::TooLarge);
|
||||||
}
|
}
|
||||||
@@ -197,13 +182,12 @@ fn verify_snapshot(
|
|||||||
public_key
|
public_key
|
||||||
.verify_strict(&payload, &signature)
|
.verify_strict(&payload, &signature)
|
||||||
.map_err(|_| RemoteError::InvalidSignature)?;
|
.map_err(|_| RemoteError::InvalidSignature)?;
|
||||||
let digest = format!("{:x}", Sha256::digest(&payload));
|
|
||||||
let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?;
|
let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?;
|
||||||
let manifest = manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)?;
|
let manifest = manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)?;
|
||||||
if manifest.id != profile_id {
|
if manifest.id != profile_id {
|
||||||
return Err(RemoteError::ProfileMismatch);
|
return Err(RemoteError::ProfileMismatch);
|
||||||
}
|
}
|
||||||
Ok(VerifiedSnapshot { manifest, digest })
|
Ok(manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+123
-144
@@ -6,7 +6,12 @@
|
|||||||
use reqwest::blocking::{Client, Response};
|
use reqwest::blocking::{Client, Response};
|
||||||
use reqwest::redirect::Policy;
|
use reqwest::redirect::Policy;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{fmt, fs, io, path::Path, time::Duration};
|
use std::{
|
||||||
|
fmt, fs,
|
||||||
|
io::{self, Read},
|
||||||
|
path::Path,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
const API_ORIGIN: &str = "https://shacraft.ru";
|
const API_ORIGIN: &str = "https://shacraft.ru";
|
||||||
const SESSION_FILE: &str = "shacraft-session";
|
const SESSION_FILE: &str = "shacraft-session";
|
||||||
@@ -45,21 +50,9 @@ pub struct LoginResult {
|
|||||||
|
|
||||||
#[derive(Clone, Deserialize, Serialize)]
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
pub struct LinkStart {
|
pub struct LinkStart {
|
||||||
pub proof_version: u32,
|
|
||||||
pub server_id: String,
|
|
||||||
pub challenge_id: i64,
|
pub challenge_id: i64,
|
||||||
pub expires_in_seconds: u64,
|
pub expires_in_seconds: u64,
|
||||||
pub registered_on_server: bool,
|
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)]
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
@@ -74,7 +67,6 @@ pub enum AccountError {
|
|||||||
Api(String),
|
Api(String),
|
||||||
Io(io::Error),
|
Io(io::Error),
|
||||||
InvalidSession,
|
InvalidSession,
|
||||||
NoLinkedNickname,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for AccountError {
|
impl fmt::Display for AccountError {
|
||||||
@@ -84,9 +76,6 @@ impl fmt::Display for AccountError {
|
|||||||
Self::Api(message) => formatter.write_str(message),
|
Self::Api(message) => formatter.write_str(message),
|
||||||
Self::Io(error) => write!(formatter, "Не удалось сохранить сессию: {error}"),
|
Self::Io(error) => write!(formatter, "Не удалось сохранить сессию: {error}"),
|
||||||
Self::InvalidSession => formatter.write_str("Сессия ShaCraft истекла — войдите снова"),
|
Self::InvalidSession => formatter.write_str("Сессия ShaCraft истекла — войдите снова"),
|
||||||
Self::NoLinkedNickname => {
|
|
||||||
formatter.write_str("Сначала привяжите игровой ник к серверу Aeronautics")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,92 +204,7 @@ pub fn start_link(
|
|||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(api_error(response));
|
return Err(api_error(response));
|
||||||
}
|
}
|
||||||
let value = response
|
response.json::<LinkStart>().map_err(AccountError::Network)
|
||||||
.json::<LinkStart>()
|
|
||||||
.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<OnboardingGrant, AccountError> {
|
|
||||||
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<LinkStatus, AccountError> {
|
pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result<LinkStatus, AccountError> {
|
||||||
@@ -318,56 +222,101 @@ pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result<LinkStatus, Acc
|
|||||||
response.json::<LinkStatus>().map_err(AccountError::Network)
|
response.json::<LinkStatus>().map_err(AccountError::Network)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn aeronautics_nickname(data_dir: &Path) -> Result<String, AccountError> {
|
const ADMISSION_ENDPOINT: &str = "/api/launcher/v2/admission/tickets";
|
||||||
get_account(data_dir)?
|
|
||||||
.links
|
/// Error responses at this boundary never echo arbitrary response bodies: a
|
||||||
.into_iter()
|
/// misconfigured proxy/service must not copy credentials into UI diagnostics.
|
||||||
.find(|link| link.server_id == "aoc")
|
fn admission_error(status: reqwest::StatusCode) -> AccountError {
|
||||||
.map(|link| link.mc_username)
|
use reqwest::StatusCode;
|
||||||
.ok_or(AccountError::NoLinkedNickname)
|
match status {
|
||||||
|
StatusCode::UNAUTHORIZED => AccountError::InvalidSession,
|
||||||
|
StatusCode::FORBIDDEN => AccountError::Api(
|
||||||
|
"Нет разрешения на вход в Aeronautics. Проверьте привязку ника и доступ к серверу в аккаунте ShaCraft.".into()),
|
||||||
|
StatusCode::CONFLICT => AccountError::Api(
|
||||||
|
"Этот ник уже занят или зарезервирован. Если это ваш игровой ник, обратитесь в поддержку ShaCraft.".into()),
|
||||||
|
StatusCode::NOT_FOUND | StatusCode::SERVICE_UNAVAILABLE => AccountError::Api(
|
||||||
|
"Вход через ShaCraft Launcher пока не настроен на сервере. Повторите попытку позже.".into()),
|
||||||
|
StatusCode::TOO_MANY_REQUESTS => AccountError::Api(
|
||||||
|
"Слишком много запросов входа. Подождите немного и повторите попытку.".into()),
|
||||||
|
_ => AccountError::Api(format!("Не удалось получить разрешение ShaCraft: HTTP {status}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checked_admission_response(
|
||||||
|
data_dir: &Path,
|
||||||
|
response: Response,
|
||||||
|
) -> Result<Response, AccountError> {
|
||||||
|
if response.status().is_success() {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
|
let _ = fs::remove_file(session_path(data_dir));
|
||||||
|
}
|
||||||
|
Err(admission_error(response.status()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server reserves a free nickname atomically for this account. Existing
|
||||||
|
/// player names remain reserved for administrator-assisted migration.
|
||||||
|
pub fn claim_nickname(data_dir: &Path, nickname: &str) -> Result<Account, AccountError> {
|
||||||
|
let token = load_session(data_dir)?;
|
||||||
|
let response = client()?
|
||||||
|
.post(format!("{API_ORIGIN}/api/launcher/v2/admission/nickname"))
|
||||||
|
.bearer_auth(token)
|
||||||
|
.json(&serde_json::json!({"server_id": "aoc", "mc_username": nickname}))
|
||||||
|
.send()
|
||||||
|
.map_err(AccountError::Network)?;
|
||||||
|
checked_admission_response(data_dir, response)?
|
||||||
|
.json()
|
||||||
|
.map_err(AccountError::Network)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called only after installation, immediately before Java spawn. Nothing in
|
||||||
|
/// this response is exposed to the webview or persisted with account settings.
|
||||||
|
pub(crate) fn issue_admission(
|
||||||
|
data_dir: &Path,
|
||||||
|
server_id: &'static str,
|
||||||
|
) -> Result<crate::admission::Admission, AccountError> {
|
||||||
|
let token = load_session(data_dir)?;
|
||||||
|
let key = crate::admission::AdmissionKey::generate(server_id)
|
||||||
|
.map_err(|message| AccountError::Api(message.into()))?;
|
||||||
|
let response = client()?
|
||||||
|
.post(format!("{API_ORIGIN}{ADMISSION_ENDPOINT}"))
|
||||||
|
.bearer_auth(token)
|
||||||
|
.json(&key.request())
|
||||||
|
.send()
|
||||||
|
.map_err(AccountError::Network)?;
|
||||||
|
let response = checked_admission_response(data_dir, response)?;
|
||||||
|
// The expected object is under 256 bytes; bound the remote allocation and
|
||||||
|
// use a fixed parse error without response values or secret-bearing bodies.
|
||||||
|
let mut body = zeroize::Zeroizing::new(Vec::new());
|
||||||
|
response.take(4097).read_to_end(&mut body).map_err(|_| {
|
||||||
|
AccountError::Api(
|
||||||
|
"Не удалось прочитать разрешение на вход. Повторите попытку позже.".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let invalid = || {
|
||||||
|
AccountError::Api(
|
||||||
|
"Сервер вернул некорректное разрешение на вход. Повторите попытку позже.".into(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if body.len() > 4096 {
|
||||||
|
return Err(invalid());
|
||||||
|
}
|
||||||
|
let payload = serde_json::from_slice(&body).map_err(|_| invalid())?;
|
||||||
|
key.bind(payload)
|
||||||
|
.map_err(|message| AccountError::Api(message.into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{load_session, save_session, session_path};
|
use super::{
|
||||||
|
admission_error, issue_admission, load_session, save_session, session_path, AccountError,
|
||||||
|
};
|
||||||
use std::{
|
use std::{
|
||||||
fs, process,
|
fs, process,
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
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 {
|
fn temporary_directory() -> std::path::PathBuf {
|
||||||
std::env::temp_dir().join(format!(
|
std::env::temp_dir().join(format!(
|
||||||
"shacraft-account-test-{}-{}",
|
"shacraft-account-test-{}-{}",
|
||||||
@@ -405,4 +354,34 @@ mod tests {
|
|||||||
);
|
);
|
||||||
fs::remove_dir_all(directory).unwrap();
|
fs::remove_dir_all(directory).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admission_without_session_never_falls_back_to_legacy_nickname() {
|
||||||
|
let directory = temporary_directory();
|
||||||
|
crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
issue_admission(&directory, "aoc"),
|
||||||
|
Err(AccountError::InvalidSession)
|
||||||
|
));
|
||||||
|
fs::remove_dir_all(directory).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admission_unavailable_and_revoked_session_have_actionable_errors() {
|
||||||
|
for status in [
|
||||||
|
reqwest::StatusCode::NOT_FOUND,
|
||||||
|
reqwest::StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
] {
|
||||||
|
assert!(admission_error(status)
|
||||||
|
.to_string()
|
||||||
|
.contains("пока не настроен"));
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
admission_error(reqwest::StatusCode::UNAUTHORIZED),
|
||||||
|
AccountError::InvalidSession
|
||||||
|
));
|
||||||
|
assert!(admission_error(reqwest::StatusCode::CONFLICT)
|
||||||
|
.to_string()
|
||||||
|
.contains("зарезервирован"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,355 +0,0 @@
|
|||||||
//! 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<PathBuf, String> {
|
|
||||||
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<String>,
|
|
||||||
}
|
|
||||||
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<String> {
|
|
||||||
self.recovery_reason.clone()
|
|
||||||
}
|
|
||||||
pub fn acquire(data: &Path, current_version: &str) -> Result<Self, String> {
|
|
||||||
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<Option<PendingUpdate>, 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<Option<String>, 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<crate::operations::SharedPermit, String> {
|
|
||||||
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<Self, String> {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+794
-605
File diff suppressed because it is too large
Load Diff
@@ -1,94 +0,0 @@
|
|||||||
//! 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
//! 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<String, Artifact>,
|
|
||||||
pub manual_packages: BTreeMap<String, Artifact>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub(crate) struct VerifiedRelease {
|
|
||||||
pub release: Release,
|
|
||||||
pub json: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_text(encoded: &str) -> Result<String, String> {
|
|
||||||
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<Version, String> {
|
|
||||||
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<Self, String> {
|
|
||||||
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<bool, String> {
|
|
||||||
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<String, Artifact>,
|
|
||||||
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<Vec<u8>, 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<T>(
|
|
||||||
bytes: &[u8],
|
|
||||||
artifact: &Artifact,
|
|
||||||
key: &str,
|
|
||||||
installer: impl FnOnce(&[u8]) -> Result<T, String>,
|
|
||||||
) -> Result<T, String> {
|
|
||||||
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<Option<Vec<u8>>, String>,
|
|
||||||
key: &str,
|
|
||||||
current: &str,
|
|
||||||
) -> Result<Option<VerifiedRelease>, 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;
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
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<usize> {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "ShaCraft Launcher",
|
"productName": "ShaCraft Launcher",
|
||||||
"version": "0.2.0",
|
"version": "0.1.7",
|
||||||
"identifier": "ru.shacraft.launcher",
|
"identifier": "ru.shacraft.launcher",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"createUpdaterArtifacts": true,
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
@@ -36,18 +37,22 @@
|
|||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"windows": {
|
"linux": {
|
||||||
"wix": {
|
"deb": {
|
||||||
"upgradeCode": "2058b1df-56a1-51ef-bd48-d296479cd59a"
|
"depends": [
|
||||||
},
|
"libwebkit2gtk-4.1-0",
|
||||||
"nsis": {
|
"libgtk-3-0",
|
||||||
"installMode": "currentUser"
|
"pkexec"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
"pubkey": "",
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEY3NUEyMDIwOUM3QTFFRTYKUldUbUhucWNJQ0JhOTFnUUo4d0Rmc1JxOVdyRElCYTRranJKRzZEYzloRGNXQ09NL1kvN042OEsK",
|
||||||
|
"endpoints": [
|
||||||
|
"https://shacraft.ru/launcher/updates/stable-v2.json"
|
||||||
|
],
|
||||||
"windows": {
|
"windows": {
|
||||||
"installMode": "passive"
|
"installMode": "passive"
|
||||||
}
|
}
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"publicKey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXkgM0FGNTgxMjY4QTBERUU5NgpSV1NXN2cyS0pvSDFPczhocEIzTmp1bjM1TnRRWWE1QnIyck00bDRmZW1sQlphQXY2MTZvTWcwZgo=",
|
||||||
|
"metadata": {
|
||||||
|
"notes": "Проверка обновления",
|
||||||
|
"platforms": {
|
||||||
|
"linux-x86_64": {
|
||||||
|
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVTVzdnMktKb0gxT2dZZmwrT0V0b0pYbDdYU3dSang1TXJNZXdtTDZvUVNvU1FMU2ZsajJkL2d4bXlOVDdQNmt4eEExeUtGcG1zckNWcEhPVXd4TnBJOU9ublZWbHk0NFFjPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTk1ODU1CWZpbGU6Zml4dHVyZS5BcHBJbWFnZQloYXNoZWQKdWxLSklQR3pabTcxdkNaUmM3d3FHbTRRSDI5dzY4UEU0QXY3NE9MazVWdGRPallpOTQ0a2RSK1AyRDBaeEZ5eGdERFgvbmVtZEluUXc4UFdUOWV6Qnc9PQo=",
|
||||||
|
"url": "https://shacraft.ru/downloads/shacraft-launcher/0.1.3/fixture.AppImage"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pub_date": "2026-09-10T00:00:00Z",
|
||||||
|
"version": "0.1.3",
|
||||||
|
"signedPayload": "eyJub3RlcyI6ItCf0YDQvtCy0LXRgNC60LAg0L7QsdC90L7QstC70LXQvdC40Y8iLCJwbGF0Zm9ybXMiOnsibGludXgteDg2XzY0Ijp7InNpZ25hdHVyZSI6ImRXNTBjblZ6ZEdWa0lHTnZiVzFsYm5RNklITnBaMjVoZEhWeVpTQm1jbTl0SUcxcGJtbHphV2R1SUhObFkzSmxkQ0JyWlhrS1VsVlRWemRuTWt0S2IwZ3hUMmRaWm13clQwVjBiMHBZYkRkWVUzZFNhbmcxVFhKTlpYZHRURFp2VVZOdlUxRk1VMlpzYWpKa0wyZDRiWGxPVkRkUU5tdDRlRUV4ZVV0R2NHMXpja05XY0VoUFZYZDRUbkJKT1U5dWJsWldiSGswTkZGalBRcDBjblZ6ZEdWa0lHTnZiVzFsYm5RNklIUnBiV1Z6ZEdGdGNEb3hOemc0T1RrMU9EVTFDV1pwYkdVNlptbDRkSFZ5WlM1QmNIQkpiV0ZuWlFsb1lYTm9aV1FLZFd4TFNrbFFSM3BhYlRjeGRrTmFVbU0zZDNGSGJUUlJTREk1ZHpZNFVFVTBRWFkzTkU5TWF6VldkR1JQYWxscE9UUTBhMlJTSzFBeVJEQmFlRVo1ZUdkRVJGZ3ZibVZ0WkVsdVVYYzRVRmRVT1dWNlFuYzlQUW89IiwidXJsIjoiaHR0cHM6Ly9zaGFjcmFmdC5ydS9kb3dubG9hZHMvc2hhY3JhZnQtbGF1bmNoZXIvMC4xLjMvZml4dHVyZS5BcHBJbWFnZSJ9fSwicHViX2RhdGUiOiIyMDI2LTA5LTEwVDAwOjAwOjAwWiIsInZlcnNpb24iOiIwLjEuMyJ9",
|
||||||
|
"metadataSignature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIG1pbmlzaWduIHNlY3JldCBrZXkKUlVTVzdnMktKb0gxT3YrcXkzN0haeGZoeHBML0pMdXJYSXRjSE1vQ2VPZkg3bFpHZjRHbWVzOG1wdlJLcWRxUlJIaW11NElydkcxMk5jWStEMGtZSEI5UXAwak1TLzdjRXdnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTk1ODU1CWZpbGU6cGF5bG9hZC5qc29uCWhhc2hlZApEMGVQWFJCQW9RVTFaRnNDbG8xMnZOekpHcy9Pb0xEN0hHaDFiMkxJQ294WDBkeEszY0s2aGl3QWMzWEtFdmtIakRxOEx4VlF5UHJoZnpYSDJ1ZEdBQT09Cg=="
|
||||||
|
},
|
||||||
|
"artifactText": "isolated ShaCraft updater fixture; not an executable"
|
||||||
|
}
|
||||||
-59
@@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUVnVElINVRoVDVpb2VGV21XT0wzYXN1WmxxTEhGZHVvYzg3S1FVcUFPVnVqa3FpRXFWbFl2T2FHN2ZmOU1FRVpQR1krREhCcU00RVpXMXYzd2s5UndnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIxCWZpbGU6bGF0ZXN0Lmpzb24KVkxhbGNUZE9NSUpTWlR3L1hDczFTTkFzbjkrK1ZyWE9EeG5xTUVBUnRYcW1kZHphNXVTTEs4OTU0WitORWdQdk9PaXNhVWVvbTFJRHFBeWRtQmdzQXc9PQo=
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ShaCraft synthetic fixture; never execute or install.
|
|
||||||
shacraft-launcher_0.3.0_linux-x86_64.AppImage
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4NUEyNTNEQjlERDZDMkIKUldRcmJOMjVQU1ZheUQ2UzVTN0NHS21ydFp1c1REajVucjlXYnFPK3ZSYWYrQVFDaUgvL3lpQ2UK
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIyODAzRjlGOUFFNDM4QzYKUldUR09PU2FueitBSWhoU1Y4S2VFK21OeWRmamVkMlBreWRjbWdtRmNtbEZrTzMwNE92MDU2d3YK
|
|
||||||
+23
-35
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { LegacyModsDialog } from './components/LegacyModsDialog'
|
import { FeedbackDialog, type Feedback } from './components/FeedbackDialog'
|
||||||
import { Library } from './components/Library'
|
import { Library } from './components/Library'
|
||||||
import { RecoveryCodesModal } from './components/RecoveryCodesModal'
|
import { RecoveryCodesModal } from './components/RecoveryCodesModal'
|
||||||
import { PlayDock } from './components/PlayDock'
|
import { PlayDock } from './components/PlayDock'
|
||||||
@@ -9,9 +9,9 @@ import { Titlebar } from './components/Titlebar'
|
|||||||
import { servers } from './data/servers'
|
import { servers } from './data/servers'
|
||||||
import { useAccount } from './hooks/useAccount'
|
import { useAccount } from './hooks/useAccount'
|
||||||
import { useLauncher } from './hooks/useLauncher'
|
import { useLauncher } from './hooks/useLauncher'
|
||||||
|
import { useLauncherUpdate } from './hooks/useLauncherUpdate'
|
||||||
import { useServerStatus } from './hooks/useServerStatus'
|
import { useServerStatus } from './hooks/useServerStatus'
|
||||||
import { useSettings } from './hooks/useSettings'
|
import { useSettings } from './hooks/useSettings'
|
||||||
import { useUpdater } from './hooks/useUpdater'
|
|
||||||
import { isNative } from './services/native'
|
import { isNative } from './services/native'
|
||||||
import { launchAccess } from './state/account'
|
import { launchAccess } from './state/account'
|
||||||
import { installStageLabels } from './state/game'
|
import { installStageLabels } from './state/game'
|
||||||
@@ -19,8 +19,8 @@ import { installStageLabels } from './state/game'
|
|||||||
export function App() {
|
export function App() {
|
||||||
const [selected, setSelected] = useState(servers[0])
|
const [selected, setSelected] = useState(servers[0])
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const [legacyOpen, setLegacyOpen] = useState(false)
|
|
||||||
const [windowError, setWindowError] = useState<string | null>(null)
|
const [windowError, setWindowError] = useState<string | null>(null)
|
||||||
|
const [errorFeedback, setErrorFeedback] = useState<Feedback | null>(null)
|
||||||
const preferences = useSettings()
|
const preferences = useSettings()
|
||||||
const session = useAccount()
|
const session = useAccount()
|
||||||
const launcher = useLauncher()
|
const launcher = useLauncher()
|
||||||
@@ -28,32 +28,25 @@ export function App() {
|
|||||||
const closeSettings = useCallback(() => setSettingsOpen(false), [])
|
const closeSettings = useCallback(() => setSettingsOpen(false), [])
|
||||||
const desktop = isNative()
|
const desktop = isNative()
|
||||||
const profile = launcher.profiles[selected.profileId]
|
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 ready = profile?.inspection?.upToDate === true
|
||||||
const operation = launcher.game.operation
|
const operation = launcher.game.operation
|
||||||
const busy = operation.phase !== 'idle'
|
const busy = operation.phase !== 'idle'
|
||||||
const access = launchAccess(session.account)
|
const access = launchAccess(session.account)
|
||||||
const checking = desktop && (!profile || profile.status === 'checking')
|
const checking = desktop && (!profile || profile.status === 'checking')
|
||||||
const settingsBlocked = !preferences.loaded || preferences.saving || !!preferences.error
|
const settingsBlocked = !preferences.loaded || preferences.saving || !!preferences.error
|
||||||
const updaterBlocked = busy ? 'Завершите игру или дождитесь окончания работы со сборкой.'
|
const updater = useLauncherUpdate(busy || session.busy || preferences.saving || session.recoveryCodes.length > 0)
|
||||||
: settingsBlocked ? 'Дождитесь сохранения настроек; при ошибке повторите сохранение.'
|
const updateLocked = updater.locksOperations
|
||||||
: 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' ||
|
const disabled = !desktop || busy || updateLocked || session.busy || access === 'loading' ||
|
||||||
(access === 'ready' && (checking || settingsBlocked || !launcher.eventsReady))
|
(access === 'ready' && (checking || settingsBlocked || !launcher.eventsReady))
|
||||||
const repairDisabled = !desktop || busy || updateLocked || checking
|
const repairDisabled = !desktop || busy || updateLocked || checking
|
||||||
const error = launcher.game.error ?? preferences.error ?? windowError ?? launcher.environmentError ?? session.error ?? profile?.error ?? null
|
const error = launcher.game.error ?? preferences.error ?? windowError ?? launcher.environmentError ?? session.error ?? profile?.error ?? null
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) setErrorFeedback({ kind: 'error', title: 'Ошибка лаунчера', message: error })
|
||||||
|
}, [error])
|
||||||
|
|
||||||
let label = 'Играть'
|
let label = 'Играть'
|
||||||
if (!desktop) label = 'В приложении'
|
if (!desktop) label = 'В приложении'
|
||||||
else if (updateLocked) label = updaterRecovery ? 'Нужно восстановить лаунчер' : 'Обновление лаунчера'
|
else if (updateLocked) label = updater.state.phase === 'ready' || updater.state.phase === 'restarting' ? 'Перезапустите лаунчер' : 'Обновляем лаунчер…'
|
||||||
else if (operation.phase === 'running') label = 'Игра запущена'
|
else if (operation.phase === 'running') label = 'Игра запущена'
|
||||||
else if (operation.phase === 'launching') label = 'Запускаем…'
|
else if (operation.phase === 'launching') label = 'Запускаем…'
|
||||||
else if (operation.phase === 'installing') label = operation.progress ? `${installStageLabels[operation.progress.stage]}…` : 'Подготовка…'
|
else if (operation.phase === 'installing') label = operation.progress ? `${installStageLabels[operation.progress.stage]}…` : 'Подготовка…'
|
||||||
@@ -66,11 +59,6 @@ export function App() {
|
|||||||
else if (checking) label = 'Проверяем…'
|
else if (checking) label = 'Проверяем…'
|
||||||
else if (!ready) 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 = () => {
|
const primary = () => {
|
||||||
if (disabled) return
|
if (disabled) return
|
||||||
if (access !== 'ready') setSettingsOpen(true)
|
if (access !== 'ready') setSettingsOpen(true)
|
||||||
@@ -80,28 +68,28 @@ export function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<Titlebar host={launcher.host} onError={setWindowError} />
|
<Titlebar host={launcher.host} onError={setWindowError} />
|
||||||
<div className="workspace" inert={session.recoveryCodes.length > 0 || legacyOpen}>
|
<div className="workspace" inert={session.recoveryCodes.length > 0}>
|
||||||
<Library selected={selected} profiles={launcher.profiles} account={session.account}
|
<Library selected={selected} profiles={launcher.profiles} account={session.account}
|
||||||
native={desktop} locked={busy || updateLocked || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} />
|
native={desktop} locked={busy || updateLocked || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} />
|
||||||
<ServerStage server={displayed} status={serverStatus} javaMajor={metadata?.javaMajor}>
|
<ServerStage server={selected} status={serverStatus}>
|
||||||
{(updater.state.status?.phase === 'available' || (updater.state.status?.phase === 'manual' && updater.state.status.availableVersion) || updater.state.status?.phase === 'ready' || updater.mutating || updaterRecovery) &&
|
<PlayDock server={selected} operation={operation} profile={profile}
|
||||||
<button className="launcher-update-notice" onClick={() => setSettingsOpen(true)}>
|
|
||||||
{updaterRecovery ? 'Нужно восстановить лаунчер' : updater.state.status?.phase === 'ready' ? 'Перезапустите лаунчер после обновления'
|
|
||||||
: updater.mutating ? 'Обновление лаунчера…' : `Доступен лаунчер ${updater.state.status?.availableVersion ?? ''}`}
|
|
||||||
<span>Открыть настройки</span>
|
|
||||||
</button>}
|
|
||||||
<PlayDock server={displayed} operation={operation} profile={profile}
|
|
||||||
memoryGb={preferences.settings.memoryMb / 1024} native={desktop}
|
memoryGb={preferences.settings.memoryMb / 1024} native={desktop}
|
||||||
needsLogin={access === 'login'} needsLink={access === 'link'}
|
needsLogin={access === 'login'} needsLink={access === 'link'}
|
||||||
error={error} label={label} primaryDisabled={disabled} repairDisabled={repairDisabled}
|
error={error} label={label} primaryDisabled={disabled} repairDisabled={repairDisabled}
|
||||||
onLegacy={() => setLegacyOpen(true)} onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
|
onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
|
||||||
</ServerStage>
|
</ServerStage>
|
||||||
</div>
|
</div>
|
||||||
|
{desktop && (updater.state.status?.version || updateLocked) && !settingsOpen && !session.recoveryCodes.length &&
|
||||||
|
<button className="update-banner" onClick={() => setSettingsOpen(true)}>
|
||||||
|
<span className="update-banner-dot" />
|
||||||
|
{updater.state.phase === 'ready' || updater.state.phase === 'restarting' ? 'Обновление установлено · перезапустить'
|
||||||
|
: updateLocked ? 'Обновляем ShaCraft Launcher…' : `ShaCraft Launcher ${updater.state.status?.version} · обновить`}
|
||||||
|
</button>}
|
||||||
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy || updateLocked} preferences={preferences}
|
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy || updateLocked} preferences={preferences}
|
||||||
updater={updater} native={desktop}
|
session={session} updater={updater} host={launcher.host} java={launcher.java} onClose={closeSettings} />
|
||||||
session={session} host={launcher.host} java={launcher.java} requiredJava={metadata?.javaMajor} onOnboard={onboard} onClose={closeSettings} />
|
|
||||||
{legacyOpen && <LegacyModsDialog profileId={selected.profileId} onClose={() => setLegacyOpen(false)} onChanged={() => { void launcher.refreshProfile(selected.profileId) }} />}
|
|
||||||
<RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} />
|
<RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} />
|
||||||
|
<FeedbackDialog feedback={session.recoveryCodes.length ? null : session.feedback ?? errorFeedback}
|
||||||
|
onDismiss={() => { session.dismissFeedback(); setErrorFeedback(null) }} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { LogOut, Users } from 'lucide-react'
|
|||||||
import type { useAccount } from '../hooks/useAccount'
|
import type { useAccount } from '../hooks/useAccount'
|
||||||
import { isNative } from '../services/native'
|
import { isNative } from '../services/native'
|
||||||
|
|
||||||
export function AccountSettings({ session, locked, onOnboard }: { session: ReturnType<typeof useAccount>; locked: boolean; onOnboard?: (nickname: string) => Promise<void> }) {
|
export function AccountSettings({ session, locked }: { session: ReturnType<typeof useAccount>; locked: boolean }) {
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [registering, setRegistering] = useState(false)
|
const [registering, setRegistering] = useState(false)
|
||||||
@@ -41,25 +41,19 @@ export function AccountSettings({ session, locked, onOnboard }: { session: Retur
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
{account && !linkedNickname && (
|
{account && !linkedNickname && (
|
||||||
<form onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
|
<form noValidate onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
|
||||||
<label className="text-setting">
|
<label className="text-setting">
|
||||||
<span><strong>Игровой ник</strong><small>Aeronautics</small></span>
|
<span><strong>Игровой ник</strong><small>Общий ник ShaCraft</small></span>
|
||||||
<input value={nickname} minLength={3} maxLength={16} pattern="[A-Za-z0-9_]{3,16}" required
|
<input value={nickname} minLength={3} maxLength={16} pattern="[A-Za-z0-9_]{3,16}" required
|
||||||
disabled={disabled || session.linking} onChange={(event) => setNickname(event.target.value)} placeholder="Player" />
|
disabled={disabled || session.linking} onChange={(event) => setNickname(event.target.value)} placeholder="Player" />
|
||||||
<small>Подтвердите владение ником на сервере Aeronautics.</small>
|
<small>Свободный ник закрепляется за аккаунтом. Для старого игрового ника обратитесь к администратору.</small>
|
||||||
</label>
|
</label>
|
||||||
<button className="setting-row" type="submit" disabled={disabled || session.linking}>
|
<button className="setting-row" type="submit" disabled={disabled || session.linking}>
|
||||||
<span>{session.linking ? 'Ожидаем подтверждения…' : 'Привязать ник через открытую игру'}</span>
|
<span>{session.linking ? 'Ожидаем подтверждения…' : busy ? 'Создаём проверку…' : 'Привязать ник'}</span>
|
||||||
</button>
|
</button>
|
||||||
{onOnboard && <button className="setting-row" type="button" disabled={disabled || session.linking || !/^[A-Za-z0-9_]{3,16}$/.test(nickname)}
|
|
||||||
onClick={() => { void onOnboard(nickname) }}><span>Установить и войти для подтверждения</span></button>}
|
|
||||||
<p className="account-hint">Нужна действующая проходка и ник в whitelist. Новый ник: /register, существующий: /login с игровым паролем. После входа выполните команду подтверждения.</p>
|
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
{account && linkedNickname && <>
|
{account && linkedNickname && <div className="setting-row static"><span>Игровой ник</span><small>{linkedNickname}</small></div>}
|
||||||
<div className="setting-row static"><span>Игровой ник</span><small>{linkedNickname}</small></div>
|
|
||||||
<button className="setting-row" type="button" disabled={disabled || session.linking} onClick={() => { void session.startLink(linkedNickname) }}><span>Повторно подтвердить этот ник в игре</span></button>
|
|
||||||
</>}
|
|
||||||
{session.linkMessage && <p className="account-hint" role="status">{session.linkMessage}</p>}
|
{session.linkMessage && <p className="account-hint" role="status">{session.linkMessage}</p>}
|
||||||
{session.error && <p className="status-error account-hint" role="alert">{session.error}</p>}
|
{session.error && <p className="status-error account-hint" role="alert">{session.error}</p>}
|
||||||
{account && <button className="setting-row" disabled={disabled} onClick={session.logout}><span><LogOut />Выйти из ShaCraft</span></button>}
|
{account && <button className="setting-row" disabled={disabled} onClick={session.logout}><span><LogOut />Выйти из ShaCraft</span></button>}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
export interface Feedback {
|
||||||
|
kind: 'info' | 'success' | 'error'
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedbackDialog({ feedback, onDismiss }: { feedback: Feedback | null; onDismiss: () => void }) {
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null)
|
||||||
|
const visible = feedback !== null
|
||||||
|
useEffect(() => {
|
||||||
|
const element = dialog.current
|
||||||
|
if (!visible || !element) return
|
||||||
|
const previous = document.activeElement
|
||||||
|
element.showModal()
|
||||||
|
return () => {
|
||||||
|
element.close()
|
||||||
|
if (previous instanceof HTMLElement && previous.isConnected) previous.focus()
|
||||||
|
}
|
||||||
|
}, [visible])
|
||||||
|
if (!feedback) return null
|
||||||
|
return (
|
||||||
|
<dialog ref={dialog} className={`feedback-dialog ${feedback.kind}`} aria-labelledby="feedback-title"
|
||||||
|
aria-describedby="feedback-message" onCancel={(event) => { event.preventDefault(); onDismiss() }}
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}>
|
||||||
|
<div aria-live={feedback.kind === 'error' ? 'assertive' : 'polite'} aria-atomic="true">
|
||||||
|
<h2 id="feedback-title">{feedback.title}</h2>
|
||||||
|
<p id="feedback-message">{feedback.message}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" autoFocus onClick={onDismiss}>Понятно</button>
|
||||||
|
</dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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 (
|
|
||||||
<section className="launcher-update" aria-labelledby="launcher-update-title">
|
|
||||||
<div className="launcher-update-heading"><h3 id="launcher-update-title">Лаунчер</h3>
|
|
||||||
<span>Версия {status?.installedVersion ?? installedVersion ?? 'уточняется'}{packageFormat && ` · ${packageFormat}`}</span></div>
|
|
||||||
{status?.testBuild && <p className="launcher-update-blocked">Тестовая сборка · тестовый канал обновлений</p>}
|
|
||||||
<p className="launcher-update-status" role="status">{!native ? 'Обновления доступны в приложении лаунчера.'
|
|
||||||
: phase ? titles[phase] : checking ? 'Читаем состояние обновления…' : 'Проверьте доступные обновления.'}</p>
|
|
||||||
{status?.availableVersion && <p>Новая версия: <strong>{status.availableVersion}</strong></p>}
|
|
||||||
{phase === 'manual' && <p>{status?.packageFormat === 'deb'
|
|
||||||
? 'Этот пакет обновляется вручную. Для установки .deb используйте системный менеджер пакетов; версия и инструкция доступны на странице выпусков.'
|
|
||||||
: 'Эта сборка обновляется вручную. Выберите пакет для своей системы на странице выпусков.'}</p>}
|
|
||||||
{phase === 'unconfigured' && <p>Для этой сборки канал обновлений недоступен. Проверка не меняет установленный лаунчер.</p>}
|
|
||||||
{phase === 'available' && <p>Установка закроет и перезапустит лаунчер. Игру потребуется завершить, изменения настроек — сохранить.</p>}
|
|
||||||
{phase === 'ready' && <p>Чтобы продолжить работу в новой версии, перезапустите лаунчер.</p>}
|
|
||||||
{needsRecovery && <p>Автоматическое продолжение недоступно. Восстановите лаунчер из пакета на странице выпусков.</p>}
|
|
||||||
{status?.message && phase !== 'error' && <p>{status.message}</p>}
|
|
||||||
{status?.releaseNotes && <details className="launcher-update-notes"><summary>Что изменилось</summary><p tabIndex={0} aria-label="Описание изменений">{status.releaseNotes}</p></details>}
|
|
||||||
{progressing && <div className="launcher-update-progress" role="progressbar" aria-label="Обновление лаунчера"
|
|
||||||
aria-valuemin={0} aria-valuemax={100} aria-valuenow={downloading && percent !== null ? percent : undefined}>
|
|
||||||
<i style={downloading && percent !== null ? { width: `${percent}%` } : undefined} />
|
|
||||||
</div>}
|
|
||||||
{downloading && <p>{percent === null
|
|
||||||
? `Скачано: ${(Math.max(0, Number.isFinite(downloadedBytes) ? downloadedBytes : 0) / 1024 / 1024).toFixed(1)} МБ`
|
|
||||||
: `Скачано: ${percent}%`}</p>}
|
|
||||||
{error && <p className="status-error" role="alert">{error}</p>}
|
|
||||||
{state.blockedReason && native && <p className="launcher-update-blocked">{state.blockedReason}</p>}
|
|
||||||
<div className="launcher-update-actions">
|
|
||||||
{phase !== 'ready' && !needsRecovery && <button type="button" disabled={!native || !canRunUpdater(state, 'check')} onClick={onCheck}>
|
|
||||||
<RefreshCw size={15} />{checking ? 'Проверяем…' : error ? 'Повторить проверку' : 'Проверить обновления'}
|
|
||||||
</button>}
|
|
||||||
{phase === 'available' && <button type="button" className="launcher-update-primary" disabled={!native || !canRunUpdater(state, 'install')} onClick={onInstall}>
|
|
||||||
<Download size={15} />Установить и перезапустить
|
|
||||||
</button>}
|
|
||||||
{phase === 'ready' && <button type="button" className="launcher-update-primary" disabled={!native || !canRunUpdater(state, 'restart')} onClick={onRestart}>
|
|
||||||
{state.pending?.command === 'restart' ? 'Перезапускаем…' : 'Перезапустить лаунчер'}
|
|
||||||
</button>}
|
|
||||||
{(phase === 'manual' || needsRecovery) && <button type="button" disabled={!native || !canRunUpdater(state, 'open')} onClick={onOpenRelease}>
|
|
||||||
{state.pending?.command === 'open' ? 'Открываем…' : 'Открыть страницу выпусков'}
|
|
||||||
</button>}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { ArrowDownToLine, RefreshCw } from 'lucide-react'
|
||||||
|
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
|
||||||
|
import { isNative } from '../services/native'
|
||||||
|
import { updatePercent } from '../state/updater'
|
||||||
|
|
||||||
|
export function LauncherUpdateSettings({ updater }: { updater: ReturnType<typeof useLauncherUpdate> }) {
|
||||||
|
const { state, blocked, eventsReady, eventError } = updater
|
||||||
|
const { phase, status, error } = state
|
||||||
|
const percent = updatePercent(state.progress)
|
||||||
|
const working = phase === 'downloading' || phase === 'installing'
|
||||||
|
const ready = phase === 'ready' || phase === 'restarting'
|
||||||
|
const checking = phase === 'loading' || phase === 'checking'
|
||||||
|
const deb = status?.installationKind === 'deb'
|
||||||
|
|
||||||
|
return <section className="launcher-update" aria-labelledby="launcher-update-title">
|
||||||
|
<div className="launcher-update-heading">
|
||||||
|
<h3 id="launcher-update-title">ShaCraft Launcher</h3>
|
||||||
|
{status?.currentVersion && <span>{status.currentVersion}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="launcher-update-status" aria-live="polite">
|
||||||
|
{!isNative() ? <p>Обновления доступны в приложении лаунчера.</p>
|
||||||
|
: ready ? <p className="update-success">Обновление установлено. Перезапустите лаунчер.</p>
|
||||||
|
: working ? <p>{phase === 'installing' ? deb
|
||||||
|
? 'Подтвердите установку в системном окне и дождитесь завершения.'
|
||||||
|
: 'Проверяем подпись и устанавливаем…'
|
||||||
|
: `Скачиваем обновление${percent === null ? '…' : ` · ${percent}%`}`}</p>
|
||||||
|
: checking ? <p>Проверяем обновления…</p>
|
||||||
|
: status?.supported === false ? <p>{status.reason || 'Для этой установки обновление доступно вручную на shacraft.ru/help#launcher.'}</p>
|
||||||
|
: status?.version ? <p className="update-success">Доступна версия {status.version}</p>
|
||||||
|
: state.checked && !error ? <p>У вас последняя версия.</p>
|
||||||
|
: <p>Проверка новой версии лаунчера.</p>}
|
||||||
|
{working && <progress aria-label={phase === 'installing' ? 'Установка обновления лаунчера' : 'Загрузка обновления лаунчера'} max={100} value={phase === 'installing' ? undefined : percent ?? undefined} />}
|
||||||
|
</div>
|
||||||
|
{status?.notes && status.version && !working && !ready && <details className="update-notes">
|
||||||
|
<summary>Что нового</summary><p>{status.notes.slice(0, 1600)}</p>
|
||||||
|
</details>}
|
||||||
|
{error && <p className="status-error update-error" role="status">{error}</p>}
|
||||||
|
{eventError && <p className="status-error update-error" role="status">{eventError} Перезапустите лаунчер, чтобы включить установку обновлений.</p>}
|
||||||
|
{deb && status?.supported && status.version && !working && !ready &&
|
||||||
|
<p className="update-hint">Для обновления deb потребуется подтверждение администратора в системном окне.</p>}
|
||||||
|
{isNative() && <div className="update-actions">
|
||||||
|
{ready ? <button className="update-primary" disabled={blocked || phase === 'restarting'} onClick={() => void updater.restart()}>
|
||||||
|
<RefreshCw />{phase === 'restarting' ? 'Перезапускаем…' : 'Перезапустить лаунчер'}
|
||||||
|
</button> : <>
|
||||||
|
{status?.supported && status.version && <button className="update-primary"
|
||||||
|
disabled={blocked || working || checking || !eventsReady} onClick={() => void updater.install()}>
|
||||||
|
<ArrowDownToLine />{working ? 'Обновляем…' : 'Обновить'}
|
||||||
|
</button>}
|
||||||
|
{status?.supported !== false && <button className="update-check" disabled={checking || working} onClick={() => void updater.check()}>
|
||||||
|
{checking ? 'Проверяем…' : error ? 'Повторить проверку' : 'Проверить обновления'}
|
||||||
|
</button>}
|
||||||
|
</>}
|
||||||
|
</div>}
|
||||||
|
{blocked && status?.supported && status.version && !working && <p className="update-hint">Завершите игру и текущие операции, чтобы обновить лаунчер.</p>}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
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<LegacyMod[]>([])
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
||||||
const [busy, setBusy] = useState(true)
|
|
||||||
const [moving, setMoving] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [backup, setBackup] = useState<string | null>(null)
|
|
||||||
const dialog = useRef<HTMLElement>(null)
|
|
||||||
const closeButton = useRef<HTMLButtonElement>(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 <div className="legacy-overlay"><section ref={dialog} className="legacy-dialog" role="dialog" aria-modal="true"
|
|
||||||
aria-labelledby="legacy-title" aria-describedby="legacy-description" tabIndex={-1}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
event.preventDefault(); event.stopPropagation()
|
|
||||||
if (!moveInFlight.current) onClose()
|
|
||||||
}
|
|
||||||
if (event.key !== 'Tab') return
|
|
||||||
const elements = dialog.current?.querySelectorAll<HTMLElement>('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()
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<h2 id="legacy-title">Проверка старых и изменённых модов</h2>
|
|
||||||
<p id="legacy-description">Эти файлы сохранены: их происхождение или содержимое отличается от ожидаемого. Здесь могут быть ваши моды. Выберите только те, которые хотите убрать из сборки в резервную копию. По умолчанию ничего не выбрано.</p>
|
|
||||||
{busy && <p role="status">{moving ? 'Переносим выбранные файлы в резервную копию…' : 'Проверяем файлы…'}</p>}
|
|
||||||
{!busy && files.length === 0 && <p>Файлов для ручного разбора нет.</p>}
|
|
||||||
<div className="legacy-list">{files.map((file) => <label key={file.path}>
|
|
||||||
<input type="checkbox" checked={selected.has(file.path)} disabled={busy} onChange={(e) => setSelected((old) => {
|
|
||||||
const next = new Set(old); if (e.target.checked) next.add(file.path); else next.delete(file.path); return next
|
|
||||||
})} />
|
|
||||||
<span><strong>{file.path}</strong><small>{(file.size / 1024 / 1024).toFixed(1)} МБ · {file.reason === 'changed_managed' ? 'Изменён после установки' : 'Неизвестное происхождение'}</small>
|
|
||||||
<small>SHA-256: {file.sha256}</small></span>
|
|
||||||
</label>)}</div>
|
|
||||||
{backup && <p role="status">Копии сохранены: {backup}. Теперь можно повторить проверку сборки.</p>}
|
|
||||||
{error && <p className="status-error" role="alert">{error}</p>}
|
|
||||||
<div className="legacy-actions"><button disabled={busy || selected.size === 0} onClick={() => { void move() }}>Перенести выбранные ({selected.size}) в резервную копию</button>
|
|
||||||
<button ref={closeButton} disabled={moving} onClick={onClose}>Закрыть</button></div>
|
|
||||||
</section></div>
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,7 @@ export function Library({ selected, profiles, account, locked, native, onSelect,
|
|||||||
<button className="rail-button active" aria-label="Настройки" onClick={onSettings}><Settings /></button>
|
<button className="rail-button active" aria-label="Настройки" onClick={onSettings}><Settings /></button>
|
||||||
</nav>
|
</nav>
|
||||||
<aside className="library-panel">
|
<aside className="library-panel">
|
||||||
<div className="library-heading"><p>Сборки</p><span>{servers.length} доступна</span></div>
|
<div className="library-heading"><p>Сборки</p><span>{servers.length} сборки</span></div>
|
||||||
<div className="server-list">
|
<div className="server-list">
|
||||||
{servers.map((server) => {
|
{servers.map((server) => {
|
||||||
const profile = profiles[server.profileId]
|
const profile = profiles[server.profileId]
|
||||||
@@ -33,7 +33,7 @@ export function Library({ selected, profiles, account, locked, native, onSelect,
|
|||||||
return (
|
return (
|
||||||
<button key={server.id} className={`server-row ${selected.id === server.id ? 'selected' : ''}`}
|
<button key={server.id} className={`server-row ${selected.id === server.id ? 'selected' : ''}`}
|
||||||
aria-pressed={selected.id === server.id} disabled={locked} onClick={() => onSelect(server)}>
|
aria-pressed={selected.id === server.id} disabled={locked} onClick={() => onSelect(server)}>
|
||||||
<span className={`server-glyph ${server.id}`} aria-hidden="true">A</span>
|
<span className={`server-glyph ${server.id}`} aria-hidden="true">{server.name.slice(0, 1)}</span>
|
||||||
<span className="server-copy"><strong>{server.name}</strong><small>{status}</small></span>
|
<span className="server-copy"><strong>{server.name}</strong><small>{status}</small></span>
|
||||||
<ChevronRight size={16} />
|
<ChevronRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ interface PlayDockProps {
|
|||||||
repairDisabled: boolean
|
repairDisabled: boolean
|
||||||
onPrimary: () => void
|
onPrimary: () => void
|
||||||
onRepair: () => void
|
onRepair: () => void
|
||||||
onLegacy: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlayDock(props: PlayDockProps) {
|
export function PlayDock(props: PlayDockProps) {
|
||||||
@@ -42,9 +41,7 @@ export function PlayDock(props: PlayDockProps) {
|
|||||||
} else {
|
} else {
|
||||||
title = needsLogin ? 'Нужен вход ShaCraft' : props.needsLink ? 'Нужно привязать ник' : profile?.status === 'checking' ? 'Проверяем сборку'
|
title = needsLogin ? 'Нужен вход ShaCraft' : props.needsLink ? 'Нужно привязать ник' : profile?.status === 'checking' ? 'Проверяем сборку'
|
||||||
: profile?.inspection?.upToDate ? 'Сборка готова' : 'Требуется проверка'
|
: profile?.inspection?.upToDate ? 'Сборка готова' : 'Требуется проверка'
|
||||||
detail = profile?.inspection
|
detail = profile?.inspection ? `${profile.inspection.managedFiles} файлов под контролем` : 'Проверяем локальные файлы'
|
||||||
? `${profile.inspection.upToDate ? 'Проверено файлов сборки' : 'Файлов в сборке'}: ${profile.inspection.managedFiles}`
|
|
||||||
: 'Проверяем локальные файлы'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -61,8 +58,7 @@ export function PlayDock(props: PlayDockProps) {
|
|||||||
<span><Globe2 size={15} /> {server.version}</span>
|
<span><Globe2 size={15} /> {server.version}</span>
|
||||||
<span><Gauge size={15} /> {memoryGb} ГБ памяти</span>
|
<span><Gauge size={15} /> {memoryGb} ГБ памяти</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="legacy-button" onClick={props.onLegacy} disabled={props.repairDisabled}>Разобрать моды</button>
|
<button className="repair-button" onClick={props.onRepair} disabled={props.repairDisabled} aria-label="Проверить файлы"><RotateCcw size={19} /></button>
|
||||||
<button className="repair-button" onClick={props.onRepair} disabled={props.repairDisabled} aria-label="Проверить сборку и восстановить игру" title="Проверить сборку и восстановить игру"><RotateCcw size={19} /></button>
|
|
||||||
<button className="play-button" disabled={props.primaryDisabled} onClick={props.onPrimary}>
|
<button className="play-button" disabled={props.primaryDisabled} onClick={props.onPrimary}>
|
||||||
<Play size={21} fill="currentColor" /><span>{props.label}</span>
|
<Play size={21} fill="currentColor" /><span>{props.label}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Users } from 'lucide-react'
|
|||||||
import { isNative } from '../services/native'
|
import { isNative } from '../services/native'
|
||||||
import type { Server, ServerStatus } from '../types/launcher'
|
import type { Server, ServerStatus } from '../types/launcher'
|
||||||
|
|
||||||
export function ServerStage({ server, status, javaMajor, children }: { server: Server; status: ServerStatus | null; javaMajor?: number; children: ReactNode }) {
|
export function ServerStage({ server, status, children }: { server: Server; status: ServerStatus | null; children: ReactNode }) {
|
||||||
const online = status?.reachable && status.online !== null && status.max !== null
|
const online = status?.reachable && status.online !== null && status.max !== null
|
||||||
? `${status.online} / ${status.max}` : !isNative() ? 'В приложении' : status === null ? 'Проверяем…' : 'Нет связи'
|
? `${status.online} / ${status.max}` : !isNative() ? 'В приложении' : status === null ? 'Проверяем…' : 'Нет связи'
|
||||||
const label = !isNative() ? 'Статус в приложении' : status === null ? 'Проверяем сервер' : status.reachable ? 'Сервер доступен' : 'Сервер недоступен'
|
const label = !isNative() ? 'Статус в приложении' : status === null ? 'Проверяем сервер' : status.reachable ? 'Сервер доступен' : 'Сервер недоступен'
|
||||||
@@ -17,7 +17,7 @@ export function ServerStage({ server, status, javaMajor, children }: { server: S
|
|||||||
<p>{server.kicker}</p><h1>{server.name}</h1><h2>{server.subtitle}</h2>
|
<p>{server.kicker}</p><h1>{server.name}</h1><h2>{server.subtitle}</h2>
|
||||||
<dl className="hero-meta">
|
<dl className="hero-meta">
|
||||||
<div><dt>Загрузчик</dt><dd>{server.loader}</dd></div>
|
<div><dt>Загрузчик</dt><dd>{server.loader}</dd></div>
|
||||||
<div><dt>Java</dt><dd>{javaMajor ? `Версия ${javaMajor}` : 'По подписанной сборке'}</dd></div>
|
<div><dt>Java</dt><dd>Версия {server.id === 'minigames' ? 25 : 21}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { FolderOpen, Wrench, X } from 'lucide-react'
|
import { FolderOpen, Wrench, X } from 'lucide-react'
|
||||||
import { AccountSettings } from './AccountSettings'
|
import { AccountSettings } from './AccountSettings'
|
||||||
import { LauncherUpdate } from './LauncherUpdate'
|
import { LauncherUpdateSettings } from './LauncherUpdateSettings'
|
||||||
import type { useUpdater } from '../hooks/useUpdater'
|
|
||||||
import type { useAccount } from '../hooks/useAccount'
|
import type { useAccount } from '../hooks/useAccount'
|
||||||
import type { useSettings } from '../hooks/useSettings'
|
import type { useSettings } from '../hooks/useSettings'
|
||||||
|
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
|
||||||
import type { JavaInstallation, NativeHost } from '../types/launcher'
|
import type { JavaInstallation, NativeHost } from '../types/launcher'
|
||||||
|
|
||||||
interface SettingsDrawerProps {
|
interface SettingsDrawerProps {
|
||||||
@@ -14,14 +14,11 @@ interface SettingsDrawerProps {
|
|||||||
java: JavaInstallation | null | undefined
|
java: JavaInstallation | null | undefined
|
||||||
preferences: ReturnType<typeof useSettings>
|
preferences: ReturnType<typeof useSettings>
|
||||||
session: ReturnType<typeof useAccount>
|
session: ReturnType<typeof useAccount>
|
||||||
|
updater: ReturnType<typeof useLauncherUpdate>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
requiredJava?: number
|
|
||||||
onOnboard?: (nickname: string) => Promise<void>
|
|
||||||
updater: ReturnType<typeof useUpdater>
|
|
||||||
native: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose, requiredJava, onOnboard, updater, native }: SettingsDrawerProps) {
|
export function SettingsDrawer({ open, locked, host, java, preferences, session, updater, onClose }: SettingsDrawerProps) {
|
||||||
const { settings, loaded, saving, error } = preferences
|
const { settings, loaded, saving, error } = preferences
|
||||||
const closeButton = useRef<HTMLButtonElement>(null)
|
const closeButton = useRef<HTMLButtonElement>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,9 +28,7 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
|
|||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') onClose()
|
if (event.key === 'Escape') onClose()
|
||||||
if (event.key !== 'Tab') return
|
if (event.key !== 'Tab') return
|
||||||
const elements = Array.from(closeButton.current?.closest('aside')?.querySelectorAll<HTMLElement>(
|
const elements = closeButton.current?.closest('aside')?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), summary')
|
||||||
'button:not(:disabled), input:not(:disabled), select:not(:disabled), summary, [tabindex="0"]',
|
|
||||||
) ?? []).filter((element) => element.getClientRects().length > 0)
|
|
||||||
const first = elements?.[0]
|
const first = elements?.[0]
|
||||||
const last = elements?.[elements.length - 1]
|
const last = elements?.[elements.length - 1]
|
||||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
|
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
|
||||||
@@ -52,31 +47,29 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
|
|||||||
<aside className={`settings-drawer ${open ? 'open' : ''}`} inert={!open} aria-hidden={!open}
|
<aside className={`settings-drawer ${open ? 'open' : ''}`} inert={!open} aria-hidden={!open}
|
||||||
role="dialog" aria-modal={open ? true : undefined} aria-labelledby="settings-title">
|
role="dialog" aria-modal={open ? true : undefined} aria-labelledby="settings-title">
|
||||||
<div className="drawer-title">
|
<div className="drawer-title">
|
||||||
<div><p>Настройки</p><h2 id="settings-title">Игра</h2></div>
|
<div><p>Настройки</p><h2 id="settings-title">Лаунчер и игра</h2></div>
|
||||||
<button ref={closeButton} onClick={onClose} aria-label="Закрыть настройки"><X /></button>
|
<button ref={closeButton} onClick={onClose} aria-label="Закрыть настройки"><X /></button>
|
||||||
</div>
|
</div>
|
||||||
<LauncherUpdate state={updater.state} native={native} installedVersion={host?.launcherVersion}
|
<LauncherUpdateSettings updater={updater} />
|
||||||
onCheck={updater.check} onInstall={updater.install} onRestart={updater.restart} onOpenRelease={updater.openRelease} />
|
|
||||||
<label className="range-setting">
|
<label className="range-setting">
|
||||||
<span><strong>Оперативная память</strong><b>{settings.memoryMb / 1024} ГБ</b></span>
|
<span><strong>Оперативная память</strong><b>{settings.memoryMb / 1024} ГБ</b></span>
|
||||||
<input type="range" min="3" max="12" step="1" value={settings.memoryMb / 1024} disabled={!loaded || locked}
|
<input type="range" min="3" max="12" step="1" value={settings.memoryMb / 1024} disabled={!loaded || locked}
|
||||||
onChange={(event) => preferences.updateRam(Number(event.target.value))} />
|
onChange={(event) => preferences.updateRam(Number(event.target.value))} />
|
||||||
<small>Для Aeronautics рекомендуется 6 ГБ</small>
|
<small>Для Aeronautics рекомендуется 6 ГБ</small>
|
||||||
</label>
|
</label>
|
||||||
<AccountSettings session={session} locked={locked || saving} onOnboard={onOnboard} />
|
<AccountSettings session={session} locked={locked || saving} />
|
||||||
<div className="setting-row static"><span><FolderOpen />Папка игры</span><small>{host ? 'В каталоге лаунчера' : 'Определяется…'}</small></div>
|
<div className="setting-row static"><span><FolderOpen />Папка игры</span><small>{host ? 'В каталоге лаунчера' : 'Определяется…'}</small></div>
|
||||||
<div className="setting-row static">
|
<div className="setting-row static">
|
||||||
<span><Wrench />Java</span>
|
<span><Wrench />Java</span>
|
||||||
<small>{java === undefined ? host ? 'Проверяем…' : 'Проверяется в приложении'
|
<small>{java === undefined ? host ? 'Проверяем…' : 'Проверяется в приложении'
|
||||||
: requiredJava === undefined ? 'Требование Java уточняется по сборке'
|
: java?.major === 21 ? 'Java 21 найдена' : java ? `Нужна Java 21 · найдена ${java.major}`
|
||||||
: java?.major === requiredJava ? `Java ${requiredJava} найдена` : java ? `Нужна Java ${requiredJava} · найдена ${java.major}`
|
: 'Лаунчер установит Java 21 автоматически'}</small>
|
||||||
: `Лаунчер установит Java ${requiredJava} автоматически`}</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-feedback" aria-live="polite">
|
<div className="settings-feedback" aria-live="polite">
|
||||||
{error && <><p className="status-error">{error}</p><button onClick={preferences.retry} disabled={locked || saving}>Повторить</button></>}
|
{error && <><p className="status-error">{error}</p><button onClick={preferences.retry} disabled={locked || saving}>Повторить</button></>}
|
||||||
{saving && <p>Сохраняем настройки…</p>}
|
{saving && <p>Сохраняем настройки…</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="drawer-note">{host ? `Данные лаунчера: ${host.dataDir}` : 'Java будет подобрана по подписанной сборке.'}</div>
|
<div className="drawer-note">{host ? `Данные лаунчера: ${host.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'}</div>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,18 +3,17 @@ import { test } from 'node:test'
|
|||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import { AccountSettings } from './AccountSettings'
|
import { AccountSettings } from './AccountSettings'
|
||||||
import { RecoveryCodesModal } from './RecoveryCodesModal'
|
import { RecoveryCodesModal } from './RecoveryCodesModal'
|
||||||
import { LegacyModsDialog } from './LegacyModsDialog'
|
import { ServerStage } from './ServerStage'
|
||||||
import { PlayDock } from './PlayDock'
|
import { Library } from './Library'
|
||||||
import { launchAccess } from '../state/account'
|
|
||||||
import { servers } from '../data/servers'
|
import { servers } from '../data/servers'
|
||||||
import type { useAccount } from '../hooks/useAccount'
|
import type { useAccount } from '../hooks/useAccount'
|
||||||
|
|
||||||
function session(): ReturnType<typeof useAccount> {
|
function session(): ReturnType<typeof useAccount> {
|
||||||
return {
|
return {
|
||||||
account: null, error: null, busy: false, recoveryCodes: [], linkMessage: null,
|
account: null, error: null, busy: false, recoveryCodes: [], linkMessage: null,
|
||||||
|
feedback: null, dismissFeedback: () => {},
|
||||||
linking: false, linkedNickname: null, authenticate: async () => true,
|
linking: false, linkedNickname: null, authenticate: async () => true,
|
||||||
logout: async () => {}, startLink: async () => {}, clearError: () => {},
|
logout: async () => {}, startLink: async () => {}, clearError: () => {},
|
||||||
acceptChallenge: () => {},
|
|
||||||
acknowledgeRecoveryCodes: () => {},
|
acknowledgeRecoveryCodes: () => {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,7 +34,7 @@ test('linked identity is displayed read-only while an unlinked account offers ve
|
|||||||
doesNotMatch(linked, /<input|Привязать ник/)
|
doesNotMatch(linked, /<input|Привязать ник/)
|
||||||
const unlinked = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: { username: 'website_login', links: [] } }} locked={false} />)
|
const unlinked = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: { username: 'website_login', links: [] } }} locked={false} />)
|
||||||
match(unlinked, /Привязать ник/)
|
match(unlinked, /Привязать ник/)
|
||||||
match(unlinked, /Aeronautics/)
|
match(unlinked, /Общий ник ShaCraft/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('recovery codes render only until explicitly acknowledged', () => {
|
test('recovery codes render only until explicitly acknowledged', () => {
|
||||||
@@ -48,49 +47,14 @@ test('recovery codes render only until explicitly acknowledged', () => {
|
|||||||
equal(renderToStaticMarkup(<RecoveryCodesModal codes={[]} onAcknowledge={() => {}} />), '')
|
equal(renderToStaticMarkup(<RecoveryCodesModal codes={[]} onAcknowledge={() => {}} />), '')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('onboarding is visible only for a signed-in unlinked account and needs an explicit nickname', () => {
|
test('both profiles remain selectable and display their own runtime requirements', () => {
|
||||||
const onOnboard = async () => {}
|
const library = renderToStaticMarkup(<Library selected={servers[1]!} profiles={{}} account={null}
|
||||||
const unlinked = { username: 'website_login', links: [] }
|
locked={false} native={false} onSelect={() => {}} onSettings={() => {}} />)
|
||||||
const html = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: unlinked }} locked={false} onOnboard={onOnboard} />)
|
match(library, /Aeronautics/)
|
||||||
match(html, /Установить и войти для подтверждения/)
|
match(library, /Minigames/)
|
||||||
match(html, /<button[^>]*type="button"[^>]*disabled=""[^>]*><span>Установить и войти для подтверждения/)
|
equal((library.match(/class="server-row/g) ?? []).length, 2)
|
||||||
match(html, /minLength="3" maxLength="16" pattern="\[A-Za-z0-9_\]\{3,16\}" required=""/)
|
const stage = (index: number) => renderToStaticMarkup(<ServerStage server={servers[index]!} status={null}>{null}</ServerStage>)
|
||||||
equal(launchAccess(unlinked), 'link')
|
match(stage(0), /Версия 21/)
|
||||||
|
match(stage(1), /Версия 25/)
|
||||||
const signedOut = renderToStaticMarkup(<AccountSettings session={session()} locked={false} onOnboard={onOnboard} />)
|
match(stage(1), /Fabric 0.19.5/)
|
||||||
doesNotMatch(signedOut, /Установить и войти для подтверждения/)
|
|
||||||
const linked = { username: 'website_login', links: [{ server_id: 'aoc', mc_username: 'Bound_Name' }] }
|
|
||||||
const linkedHtml = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: linked, linkedNickname: 'Bound_Name' }} locked={false} onOnboard={onOnboard} />)
|
|
||||||
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(<PlayDock server={servers[0]} operation={{ phase: 'idle' }}
|
|
||||||
profile={{ status: 'checked', error: null, inspection: { root: '/test/profile', managedFiles: 12, missingFiles: 0, mismatchedFiles: 0, upToDate: true, legacyFiles: 2 } }}
|
|
||||||
memoryGb={6} native needsLogin={access === 'login'} needsLink={access === 'link'} error={null}
|
|
||||||
label="Привязать ник" primaryDisabled={false} repairDisabled={false} onPrimary={() => {}} onRepair={() => {}} onLegacy={() => {}} />)
|
|
||||||
match(html, /Нужно привязать ник/)
|
|
||||||
doesNotMatch(html, /Сборка готова|файлов под контролем/)
|
|
||||||
match(html, /Проверено файлов сборки: 12/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('incomplete inspection does not claim that all pack files were verified', () => {
|
|
||||||
const html = renderToStaticMarkup(<PlayDock server={servers[0]} operation={{ phase: 'idle' }}
|
|
||||||
profile={{ status: 'checked', error: null, inspection: { root: '/test/profile', managedFiles: 12, missingFiles: 1, mismatchedFiles: 2, upToDate: false } }}
|
|
||||||
memoryGb={6} native needsLogin={false} needsLink={false} error={null}
|
|
||||||
label="Проверить" primaryDisabled={false} repairDisabled={false} onPrimary={() => {}} 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(<LegacyModsDialog profileId="aeronautics" onClose={() => {}} onChanged={() => {}} />)
|
|
||||||
match(html, /role="dialog" aria-modal="true" aria-labelledby="legacy-title" aria-describedby="legacy-description"/)
|
|
||||||
match(html, /По умолчанию ничего не выбрано/)
|
|
||||||
match(html, /<button disabled="">Перенести выбранные \(0\) в резервную копию<\/button>/)
|
|
||||||
match(html, /<button>Закрыть<\/button>/)
|
|
||||||
doesNotMatch(html, /checked=""/)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,86 +1,75 @@
|
|||||||
import { doesNotMatch, match } from 'node:assert/strict'
|
import { doesNotMatch, match } from 'node:assert/strict'
|
||||||
import { test } from 'node:test'
|
import { test } from 'node:test'
|
||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import { LauncherUpdate } from './LauncherUpdate'
|
import { LauncherUpdateSettings } from './LauncherUpdateSettings'
|
||||||
import { initialUpdaterState } from '../state/updater'
|
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
|
||||||
import type { UpdaterState } from '../state/updater'
|
import { initialUpdaterState, updaterReducer, updateBlocksOperations, type UpdaterState } from '../state/updater'
|
||||||
import type { UpdaterStatus } from '../types/updater'
|
|
||||||
|
|
||||||
function status(phase: UpdaterStatus['phase'], extra: Partial<UpdaterStatus> = {}): UpdaterStatus {
|
function render(state: UpdaterState): string {
|
||||||
return { revision: 1, installedVersion: '0.2.0', testBuild: false, packageFormat: 'development', phase, availableVersion: null, releaseNotes: null,
|
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||||
downloadedBytes: 0, totalBytes: null, canRetry: false, message: null, ...extra }
|
const previousTauri = Object.getOwnPropertyDescriptor(globalThis, 'isTauri')
|
||||||
}
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: { isTauri: true } })
|
||||||
function render(value: UpdaterStatus, extra: Partial<UpdaterState> = {}, native = true) {
|
Object.defineProperty(globalThis, 'isTauri', { configurable: true, value: true })
|
||||||
return renderToStaticMarkup(<LauncherUpdate state={{ ...initialUpdaterState, status: value, ...extra }} native={native}
|
try {
|
||||||
onCheck={() => {}} onInstall={() => {}} onRestart={() => {}} onOpenRelease={() => {}} />)
|
const updater: ReturnType<typeof useLauncherUpdate> = {
|
||||||
|
state, blocked: false, eventsReady: true, eventError: null,
|
||||||
|
locksOperations: updateBlocksOperations(state), check: async () => {},
|
||||||
|
install: async () => {}, restart: async () => {},
|
||||||
|
}
|
||||||
|
return renderToStaticMarkup(<LauncherUpdateSettings updater={updater} />)
|
||||||
|
} finally {
|
||||||
|
if (previous) Object.defineProperty(globalThis, 'window', previous)
|
||||||
|
else Reflect.deleteProperty(globalThis, 'window')
|
||||||
|
if (previousTauri) Object.defineProperty(globalThis, 'isTauri', previousTauri)
|
||||||
|
else Reflect.deleteProperty(globalThis, 'isTauri')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('available updater notes are plain text and installation explicitly includes restart', () => {
|
const available = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
|
||||||
const html = render(status('available', { availableVersion: '0.3.0', releaseNotes: '<img src=x onerror="alert(1)">\n[Link](https://example.invalid)' }))
|
status: { currentVersion: '0.1.4', supported: true, installationKind: 'deb', version: '0.1.5' } })
|
||||||
match(html, /Версия 0\.2\.0/)
|
|
||||||
match(html, /Новая версия: <strong>0\.3\.0/)
|
test('deb announces system authorization before installation without collecting credentials', () => {
|
||||||
match(html, /Установить и перезапустить/)
|
const html = render(available)
|
||||||
match(html, /Установка закроет и перезапустит лаунчер/)
|
match(html, /Для обновления deb потребуется подтверждение администратора в системном окне/)
|
||||||
match(html, /<img src=x/)
|
match(html, /class="update-primary"><[^>]+.*Обновить<\/button>/)
|
||||||
doesNotMatch(html, /<img|<a\s|dangerouslySetInnerHTML|authenticode|notarization/i)
|
doesNotMatch(html, /<input|type="password"|Перезапустить лаунчер/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('an unconfigured build still reports its installed native version', () => {
|
test('deb installation requests system confirmation and prevents duplicate install or check', () => {
|
||||||
const html = render(status('unconfigured'))
|
const downloading = updaterReducer(available, { type: 'install' })
|
||||||
match(html, /Версия 0\.2\.0/)
|
const installing = updaterReducer(downloading, { type: 'progress',
|
||||||
match(html, /Автообновление пока не настроено/)
|
progress: { stage: 'installing', downloadedBytes: 10, totalBytes: 10 } })
|
||||||
doesNotMatch(html, /Установить и перезапустить/)
|
const html = render(installing)
|
||||||
|
match(html, /Подтвердите установку в системном окне и дождитесь завершения/)
|
||||||
|
match(html, /aria-label="Установка обновления лаунчера"/)
|
||||||
|
match(html, /class="update-primary" disabled=""/)
|
||||||
|
match(html, /class="update-check" disabled=""/)
|
||||||
|
doesNotMatch(html, /Для обновления deb потребуется|<input|type="password"/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('test updater builds have a visible native-provided notice', () => {
|
test('cancelled deb authorization remains visible and permits explicit retry without claiming success', () => {
|
||||||
match(render(status('available', { testBuild: true })), /Тестовая сборка · тестовый канал обновлений/)
|
const downloading = updaterReducer(available, { type: 'install' })
|
||||||
doesNotMatch(render(status('available')), /Тестовая сборка/)
|
const installing = updaterReducer(downloading, { type: 'progress',
|
||||||
|
progress: { stage: 'installing', downloadedBytes: 10 } })
|
||||||
|
const cancelled = updaterReducer(installing, { type: 'failed', error: 'Установка отменена в системном окне.' })
|
||||||
|
const html = render(cancelled)
|
||||||
|
match(html, /role="status">Установка отменена в системном окне/)
|
||||||
|
match(html, /class="update-primary">/)
|
||||||
|
match(html, /Повторить проверку/)
|
||||||
|
doesNotMatch(html, /disabled=""|Обновление установлено|Перезапустить лаунчер/)
|
||||||
|
const retry = render(updaterReducer(cancelled, { type: 'install' }))
|
||||||
|
match(retry, /Скачиваем обновление/)
|
||||||
|
doesNotMatch(retry, /Установка отменена/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('manual Linux packages offer release instructions without a native install button or frontend link', () => {
|
test('AppImage and older native status retain their installation copy without administrator hints', () => {
|
||||||
const html = render(status('manual', { availableVersion: '0.3.0', packageFormat: 'deb' }))
|
for (const installationKind of ['appimage', undefined] as const) {
|
||||||
match(html, /Версия 0\.2\.0 · deb/)
|
const status = { ...available.status!, installationKind }
|
||||||
match(html, /системный менеджер пакетов/)
|
const downloading = updaterReducer({ ...available, status }, { type: 'install' })
|
||||||
match(html, /Открыть страницу выпусков/)
|
const installing = updaterReducer(downloading, { type: 'progress',
|
||||||
doesNotMatch(html, /Установить и перезапустить|href=/)
|
progress: { stage: 'installing', downloadedBytes: 10 } })
|
||||||
})
|
const html = render(installing)
|
||||||
|
match(html, /Проверяем подпись и устанавливаем/)
|
||||||
test('pending game or settings work disables the explicit update action and explains why', () => {
|
doesNotMatch(html, /администратора|системном окне/)
|
||||||
const html = render(status('available'), { blockedReason: 'Игра запущена' })
|
}
|
||||||
match(html, /Игра запущена/)
|
|
||||||
match(html, /<button[^>]*class="launcher-update-primary"[^>]*disabled=""/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('unknown download size does not announce a false percentage', () => {
|
|
||||||
const html = render(status('downloading', { downloadedBytes: 1048576 }))
|
|
||||||
match(html, /Скачано: 1\.0 МБ/)
|
|
||||||
match(html, /role="progressbar"/)
|
|
||||||
doesNotMatch(html, /aria-valuenow|Скачано: 0%/)
|
|
||||||
match(render(status('downloading', { downloadedBytes: 50, totalBytes: 100 })), /aria-valuenow="50"/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('verification, installation and restart fallback remain distinct', () => {
|
|
||||||
match(render(status('verifying')), /Проверяем подпись обновления/)
|
|
||||||
match(render(status('installing')), /Устанавливаем обновление/)
|
|
||||||
const ready = render(status('ready'), { error: 'Не удалось перезапустить' })
|
|
||||||
match(ready, /Перезапустить лаунчер/)
|
|
||||||
match(ready, /role="alert">Не удалось перезапустить/)
|
|
||||||
doesNotMatch(ready, /Установить и перезапустить|Проверить обновления/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('check failures allow a clear retry, and an up-to-date result needs no install action', () => {
|
|
||||||
match(render(status('error', { canRetry: true, message: 'Сеть недоступна' })), /Повторить проверку/)
|
|
||||||
const current = render(status('no_update'))
|
|
||||||
match(current, /Установлена актуальная версия/)
|
|
||||||
doesNotMatch(current, /Установить и перезапустить/)
|
|
||||||
const preview = render(status('available'), {}, false)
|
|
||||||
match(preview, /Обновления доступны в приложении лаунчера/)
|
|
||||||
match(preview, /class="launcher-update-primary" disabled=""/)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('an indeterminate install failure offers manual recovery rather than retrying installation', () => {
|
|
||||||
const html = render(status('error', { canRetry: false, message: 'Не удалось определить результат установки.' }))
|
|
||||||
match(html, /Восстановите лаунчер из пакета/)
|
|
||||||
match(html, /Открыть страницу выпусков/)
|
|
||||||
doesNotMatch(html, /Повторить проверку|Установить и перезапустить|Перезапустить лаунчер/)
|
|
||||||
})
|
})
|
||||||
|
|||||||
+7
-2
@@ -8,8 +8,13 @@ export const servers: readonly [Server, ...Server[]] = [
|
|||||||
kicker: 'Основная сборка',
|
kicker: 'Основная сборка',
|
||||||
name: 'Aeronautics',
|
name: 'Aeronautics',
|
||||||
subtitle: 'Строй корабли. Поднимай города в небо.',
|
subtitle: 'Строй корабли. Поднимай города в небо.',
|
||||||
version: 'Версия уточняется',
|
version: '1.21.1 · NeoForge 21.1.248',
|
||||||
loader: 'По подписанной сборке',
|
loader: 'NeoForge 21.1.248',
|
||||||
profileId: 'aeronautics',
|
profileId: 'aeronautics',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'minigames', kicker: 'Лобби и арены', name: 'Minigames',
|
||||||
|
subtitle: 'Небесные острова. Сражения на аренах SMASH.',
|
||||||
|
version: '26.2 · Fabric 0.19.5', loader: 'Fabric 0.19.5', profileId: 'minigames',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
+37
-65
@@ -1,15 +1,10 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import type { Feedback } from '../components/FeedbackDialog'
|
||||||
import { createRequestScope, errorMessage } from '../services/async'
|
import { createRequestScope, errorMessage } from '../services/async'
|
||||||
import { isNative, native } from '../services/native'
|
import { isNative, native } from '../services/native'
|
||||||
import { linkedNickname, validCredentials } from '../state/account'
|
import { linkedNickname, validCredentials } from '../state/account'
|
||||||
import { isValidNickname } from '../state/settings'
|
import { isValidNickname } from '../state/settings'
|
||||||
import type { ShaCraftAccount, LinkChallenge } from '../types/launcher'
|
import type { ShaCraftAccount } from '../types/launcher'
|
||||||
|
|
||||||
interface PendingLink {
|
|
||||||
challengeId: number
|
|
||||||
expiresAt: number
|
|
||||||
isCurrent: () => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAccount() {
|
export function useAccount() {
|
||||||
// undefined = restoring saved account; null = signed out.
|
// undefined = restoring saved account; null = signed out.
|
||||||
@@ -17,8 +12,12 @@ export function useAccount() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
|
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
|
||||||
const [challenge, setChallenge] = useState<PendingLink | null>(null)
|
|
||||||
const [linkMessage, setLinkMessage] = useState<string | null>(null)
|
const [linkMessage, setLinkMessage] = useState<string | null>(null)
|
||||||
|
const [feedback, setFeedback] = useState<Feedback | null>(null)
|
||||||
|
const reportLink = useCallback((message: string, kind: Feedback['kind'] = 'info') => {
|
||||||
|
setLinkMessage(message)
|
||||||
|
setFeedback({ kind, title: kind === 'error' ? 'Не удалось привязать ник' : 'Привязка игрового ника', message })
|
||||||
|
}, [])
|
||||||
const pending = useRef(false)
|
const pending = useRef(false)
|
||||||
const requests = useRef(createRequestScope())
|
const requests = useRef(createRequestScope())
|
||||||
|
|
||||||
@@ -35,44 +34,6 @@ export function useAccount() {
|
|||||||
return () => { active = false; requests.current.invalidate() }
|
return () => { active = false; requests.current.invalidate() }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!challenge) return
|
|
||||||
let active = true
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
const current = () => active && challenge.isCurrent()
|
|
||||||
const poll = async () => {
|
|
||||||
if (!current()) return
|
|
||||||
if (Date.now() >= challenge.expiresAt) {
|
|
||||||
setChallenge(null)
|
|
||||||
setLinkMessage('Срок проверки истёк. Начните привязку ещё раз.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await native.linkStatus(challenge.challengeId)
|
|
||||||
if (!current()) return
|
|
||||||
if (result.status === 'verified') {
|
|
||||||
const refreshed = await native.getAccount()
|
|
||||||
if (!current()) return
|
|
||||||
setAccount(refreshed)
|
|
||||||
setChallenge(null)
|
|
||||||
setLinkMessage(linkedNickname(refreshed) ? 'Ник подтверждён.' : 'Не удалось подтвердить привязку. Войдите снова.')
|
|
||||||
} else if (result.status === 'expired' || result.status === 'conflict') {
|
|
||||||
setChallenge(null)
|
|
||||||
setLinkMessage(result.detail || 'Проверка завершилась. Попробуйте ещё раз.')
|
|
||||||
} else {
|
|
||||||
// One request at a time; dispose and logout cancel future polling.
|
|
||||||
timer = setTimeout(() => { void poll() }, 3_000)
|
|
||||||
}
|
|
||||||
} catch (reason) {
|
|
||||||
if (!current()) return
|
|
||||||
setChallenge(null)
|
|
||||||
setLinkMessage(errorMessage(reason, 'Не удалось проверить ник'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
timer = setTimeout(() => { void poll() }, 3_000)
|
|
||||||
return () => { active = false; clearTimeout(timer) }
|
|
||||||
}, [challenge])
|
|
||||||
|
|
||||||
const authenticate = async (username: string, password: string, register: boolean) => {
|
const authenticate = async (username: string, password: string, register: boolean) => {
|
||||||
if (pending.current || account === undefined) return false
|
if (pending.current || account === undefined) return false
|
||||||
if (!validCredentials(username, password)) {
|
if (!validCredentials(username, password)) {
|
||||||
@@ -92,7 +53,6 @@ export function useAccount() {
|
|||||||
const result = await native.authenticate(username, password, register)
|
const result = await native.authenticate(username, password, register)
|
||||||
if (!currentRequest()) return false
|
if (!currentRequest()) return false
|
||||||
setAccount(result.account)
|
setAccount(result.account)
|
||||||
setChallenge(null)
|
|
||||||
setLinkMessage(null)
|
setLinkMessage(null)
|
||||||
setRecoveryCodes(result.recoveryCodes)
|
setRecoveryCodes(result.recoveryCodes)
|
||||||
return true
|
return true
|
||||||
@@ -109,7 +69,6 @@ export function useAccount() {
|
|||||||
if (!isNative() || pending.current) return
|
if (!isNative() || pending.current) return
|
||||||
pending.current = true
|
pending.current = true
|
||||||
requests.current.invalidate()
|
requests.current.invalidate()
|
||||||
setChallenge(null)
|
|
||||||
setLinkMessage(null)
|
setLinkMessage(null)
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -125,31 +84,43 @@ export function useAccount() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const acceptChallenge = (started: LinkChallenge) => {
|
|
||||||
requests.current.invalidate()
|
|
||||||
const current = requests.current.capture()
|
|
||||||
setLinkMessage(`${started.registered_on_server ? 'Войдите на Aeronautics: /login <пароль>.' : 'Войдите на Aeronautics: /register <пароль> <пароль>.'} Затем подтвердите свой аккаунт командой /shacraft link ${started.challenge_id} ${started.proof_code}`)
|
|
||||||
setChallenge({ challengeId: started.challenge_id,
|
|
||||||
expiresAt: Date.now() + started.expires_in_seconds * 1000, isCurrent: current })
|
|
||||||
}
|
|
||||||
|
|
||||||
const startLink = async (nickname: string) => {
|
const startLink = async (nickname: string) => {
|
||||||
if (!isNative() || pending.current || challenge || !account) return
|
if (pending.current) return
|
||||||
|
if (!isNative()) {
|
||||||
|
reportLink('Привязка доступна в приложении лаунчера.', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!account) {
|
||||||
|
reportLink('Войдите в аккаунт ShaCraft, затем повторите привязку.', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nickname = nickname.trim()
|
||||||
if (!isValidNickname(nickname)) {
|
if (!isValidNickname(nickname)) {
|
||||||
setLinkMessage('Ник: 3–16 латинских букв, цифр или _')
|
reportLink('Ник: 3–16 латинских букв, цифр или _', 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pending.current = true
|
pending.current = true
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setLinkMessage('Создаём проверку…')
|
setError(null)
|
||||||
|
reportLink('Проверяем аккаунт и закрепляем ник…')
|
||||||
requests.current.invalidate()
|
requests.current.invalidate()
|
||||||
const currentRequest = requests.current.capture()
|
const currentRequest = requests.current.capture()
|
||||||
try {
|
try {
|
||||||
const started = await native.startLink(nickname)
|
const refreshed = await native.getAccount()
|
||||||
if (!currentRequest()) return
|
if (!currentRequest()) return
|
||||||
acceptChallenge(started)
|
setAccount(refreshed)
|
||||||
|
if (!refreshed) {
|
||||||
|
reportLink('Сессия завершена или аккаунт удалён. Войдите в ShaCraft снова; если аккаунт удалён, создайте новый.', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const linkedAccount = await native.claimNickname(nickname)
|
||||||
|
if (!currentRequest()) return
|
||||||
|
setAccount(linkedAccount)
|
||||||
|
const confirmed = linkedNickname(linkedAccount)
|
||||||
|
reportLink(confirmed ? `Ник ${confirmed} закреплён за аккаунтом. Теперь можно запускать игру.`
|
||||||
|
: 'Не удалось получить закреплённый ник. Войдите снова.', confirmed ? 'success' : 'error')
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setLinkMessage(errorMessage(reason, 'Не удалось начать привязку'))
|
if (currentRequest()) reportLink(errorMessage(reason, 'Не удалось начать привязку'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
pending.current = false
|
pending.current = false
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
@@ -157,8 +128,9 @@ export function useAccount() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
account, error, busy, recoveryCodes, linkMessage, linking: challenge !== null,
|
account, error, busy, recoveryCodes, linkMessage, linking: false,
|
||||||
linkedNickname: linkedNickname(account), authenticate, logout, startLink, acceptChallenge,
|
feedback, dismissFeedback: () => setFeedback(null),
|
||||||
|
linkedNickname: linkedNickname(account), authenticate, logout, startLink,
|
||||||
clearError: () => setError(null),
|
clearError: () => setError(null),
|
||||||
acknowledgeRecoveryCodes: () => setRecoveryCodes([]),
|
acknowledgeRecoveryCodes: () => setRecoveryCodes([]),
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user