Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cacd1cd2bd | ||
|
|
daefc3f5e7 | ||
|
|
738399b452 | ||
|
|
cbc727829c | ||
|
|
696c6b0e52 | ||
|
|
b2ac174398 | ||
|
|
1d08396b80 | ||
|
|
7c0eb20894 | ||
|
|
189068be63 | ||
|
|
22a78530c0 | ||
|
|
cd5c675637 |
@@ -0,0 +1,2 @@
|
|||||||
|
# Signed updater fixtures must retain their exact committed bytes on every OS.
|
||||||
|
src-tauri/tests/fixtures/updater/* -text
|
||||||
+49
-60
@@ -1,94 +1,83 @@
|
|||||||
name: Cross-platform build
|
name: Cross-platform build (disposable signatures)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: [main, codex/server-migration-20260917]
|
branches: [main]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: ${{ matrix.name }}
|
name: CI test bundle / ${{ matrix.platform }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- name: Linux x64
|
- platform: linux-x86_64
|
||||||
os: ubuntu-22.04
|
os: ubuntu-22.04
|
||||||
args: --bundles appimage,deb
|
target: x86_64-unknown-linux-gnu
|
||||||
- name: Windows x64
|
bundles: appimage,deb
|
||||||
|
- platform: windows-x86_64
|
||||||
os: windows-2022
|
os: windows-2022
|
||||||
args: --bundles nsis,msi
|
target: x86_64-pc-windows-msvc
|
||||||
- name: macOS Apple Silicon
|
bundles: nsis,msi
|
||||||
|
- platform: darwin-aarch64
|
||||||
os: macos-15
|
os: macos-15
|
||||||
args: --target aarch64-apple-darwin --bundles app,dmg
|
target: aarch64-apple-darwin
|
||||||
- name: macOS Intel
|
bundles: dmg,app
|
||||||
|
- platform: darwin-x86_64
|
||||||
os: macos-15-intel
|
os: macos-15-intel
|
||||||
args: --target x86_64-apple-darwin --bundles app,dmg
|
target: x86_64-apple-darwin
|
||||||
|
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
|
||||||
- name: Install Linux system dependencies
|
with:
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
- name: Install Linux desktop dependencies
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
# Ubuntu 22.04 keeps distro sources here; unrelated vendor repos are not needed.
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
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 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
|
||||||
# These are build/test artifacts. Release signing happens separately with
|
- name: Generate disposable CI key (never a production secret)
|
||||||
# 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: |
|
||||||
python3 - <<'PY'
|
python scripts/release.py ci-key --directory "$RUNNER_TEMP/ci-updater"
|
||||||
import json
|
python -c "import json,os; open(os.environ['GITHUB_ENV'],'a').write('RELEASE_VERSION='+json.load(open('package.json'))['version']+'\n')"
|
||||||
from pathlib import Path
|
- name: Build packages and updater bundles
|
||||||
import tarfile
|
run: npm run tauri:build -- --ci --target '${{ matrix.target }}' --bundles '${{ matrix.bundles }}' --config "$RUNNER_TEMP/ci-updater/updater-build.json"
|
||||||
apps = list(Path('src-tauri/target').glob('*/release/bundle/macos/*.app'))
|
- name: Collect and verify every test bundle
|
||||||
if len(apps) != 1:
|
run: python scripts/release.py collect --bundle 'src-tauri/target/${{ matrix.target }}/release/bundle' --directory ci-packages --platform '${{ matrix.platform }}' --version "$RELEASE_VERSION"
|
||||||
raise SystemExit('Expected exactly one macOS application bundle')
|
- uses: actions/upload-artifact@v4
|
||||||
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: shacraft-launcher-windows-x64
|
name: CI-NOT-FOR-RELEASE-${{ matrix.platform }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
path: |
|
retention-days: 7
|
||||||
src-tauri/target/release/bundle/nsis/*.exe
|
path: ci-packages/*
|
||||||
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
|
|
||||||
|
|||||||
+13
-26
@@ -13,30 +13,6 @@ 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:
|
||||||
@@ -45,12 +21,23 @@ 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: |
|
||||||
sudo apt-get update
|
# Ubuntu 22.04 keeps distro sources here; unrelated vendor repos are not needed.
|
||||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
|
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 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
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
name: Publish verified release (operator only)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Version of the complete signed draft
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
tag:
|
||||||
|
description: Existing draft tag (vVERSION)
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
confirmation:
|
||||||
|
description: Type publish vVERSION to confirm public publication
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: launcher-release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
preflight:
|
||||||
|
if: github.repository == 'emil28092005/shacraft-launcher' && github.ref == 'refs/heads/main'
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
outputs:
|
||||||
|
environment: ${{ steps.gate.outputs.environment }}
|
||||||
|
commit: ${{ steps.source.outputs.commit }}
|
||||||
|
env:
|
||||||
|
RELEASE_VERSION: ${{ inputs.version }}
|
||||||
|
RELEASE_TAG: ${{ inputs.tag }}
|
||||||
|
CONFIRMATION: ${{ inputs.confirmation }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
- name: Validate publication inputs
|
||||||
|
run: python -c "import os,sys; sys.path.insert(0,'scripts'); import release; release.version_tag(os.environ['RELEASE_VERSION'],os.environ['RELEASE_TAG']); release.require(os.environ['CONFIRMATION']=='publish '+os.environ['RELEASE_TAG'],'publication confirmation mismatch')"
|
||||||
|
- name: Require existing independent approval and protected-branch policy
|
||||||
|
id: gate
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: python scripts/release_github.py gate launcher-release-publish
|
||||||
|
- name: Resolve tag using trusted workflow Git commands
|
||||||
|
id: source
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RELEASE_COMMIT=$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}")
|
||||||
|
git merge-base --is-ancestor "$RELEASE_COMMIT" refs/remotes/origin/main
|
||||||
|
printf 'commit=%s\n' "$RELEASE_COMMIT" >> "$GITHUB_OUTPUT"
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ steps.source.outputs.commit }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Check source versions after immutable source validation
|
||||||
|
run: python scripts/release.py preflight --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --check-git
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: preflight
|
||||||
|
environment: ${{ needs.preflight.outputs.environment }}
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
actions: read
|
||||||
|
env:
|
||||||
|
RELEASE_VERSION: ${{ inputs.version }}
|
||||||
|
RELEASE_TAG: ${{ inputs.tag }}
|
||||||
|
CONFIRMATION: ${{ inputs.confirmation }}
|
||||||
|
SHACRAFT_UPDATER_PUBLIC_KEY: ${{ vars.SHACRAFT_UPDATER_PUBLIC_KEY }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.preflight.outputs.commit }}
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- run: cargo build --locked --release --manifest-path scripts/release-verifier/Cargo.toml
|
||||||
|
- name: Re-download, verify signatures/metadata/assets and explicitly publish
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: python scripts/release_github.py publish --version "$RELEASE_VERSION" --tag "$RELEASE_TAG" --confirmation "$CONFIRMATION"
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
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"
|
||||||
+1
-3
@@ -11,6 +11,4 @@ src-tauri/gen/
|
|||||||
*.pfx
|
*.pfx
|
||||||
*.sig
|
*.sig
|
||||||
|
|
||||||
admission-client/.gradle/
|
__pycache__/
|
||||||
admission-client/build/
|
|
||||||
graphify-out/
|
|
||||||
|
|||||||
@@ -4,9 +4,8 @@
|
|||||||
|
|
||||||
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. Profiles are **Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21)
|
network and process-adjacent work. The current production profile is
|
||||||
and **Minigames** (Minecraft 26.2, Fabric 0.19.5, Java 25). See the Minigames
|
**Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21).
|
||||||
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
|
||||||
@@ -22,9 +21,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
|||||||
|
|
||||||
## Trust model
|
## Trust model
|
||||||
|
|
||||||
- The only supported remote profile manifest endpoints are the fixed
|
- The only supported remote profile manifest endpoint is
|
||||||
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest` and
|
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest`.
|
||||||
`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.
|
||||||
@@ -32,8 +30,12 @@ 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.
|
||||||
- `src-tauri/src/profile.rs` downloads to a temporary sibling file, verifies
|
- `profile.rs` / `inventory.rs` stage verified files, journal replacements and
|
||||||
size + SHA-256, and atomically replaces only launcher-managed files.
|
retire only previously owned, unchanged managed files. Unknown files are not
|
||||||
|
silently adopted or deleted. Legacy mod backup requires explicit selection.
|
||||||
|
- Play/Repair use one `remote::VerifiedSnapshot` from verification through spawn.
|
||||||
|
`installation_lock.rs` protects the shared game tree across processes and
|
||||||
|
retains a PID + start-time lease while Minecraft is alive.
|
||||||
- This ShaCraft manifest is the **only** source of truth for which
|
- 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
|
||||||
@@ -43,62 +45,29 @@ 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 and never
|
revocable session token is stored locally with mode 600 on Unix. At launch,
|
||||||
passed to Java. The new admission flow uses fixed POST endpoints
|
the nickname is fetched from the verified `aoc` account link; the legacy
|
||||||
`/api/launcher/v2/admission/nickname` and
|
nickname in `settings.json` is ignored as an identity source. Server-side
|
||||||
`/api/launcher/v2/admission/tickets`; redirects are rejected. Neither the
|
whitelist enforcement and LoginSystem remain the final access-control
|
||||||
manifest nor the webview can select their origin or URL.
|
boundary, including for old launcher versions. The explicit onboarding command
|
||||||
- Free nicknames are claimed directly for the authenticated account. Existing
|
is the only unlinked launch path: a short-lived server grant goes to the game
|
||||||
player names are reserved server-side and assigned by an administrator;
|
child environment only, never IPC responses, argv or files. The signed pack
|
||||||
deleting a website account must not make an existing player's name free.
|
must contain ShaCraft Game Bridge; it binds the grant to the game session,
|
||||||
- After installation, `admission.rs` generates an ephemeral Ed25519 key with
|
requires LoginSystem authentication and an explicit one-time proof command.
|
||||||
the OS CSPRNG. The backend binds its public key to a one-use ticket, current
|
Existing links retain legacy provenance; status polling cannot create links.
|
||||||
account session, canonical `aoc` nickname and server access. Only that
|
|
||||||
returned nickname selects launch identity; `settings.json` is never a fallback.
|
- Launcher self-update is a separate trust domain in `updater.rs` and
|
||||||
Ticket and PKCS#8 private key go only in the final Java child's environment
|
`updater/protocol.rs`: the fixed GitHub repository's `latest.json` bytes are
|
||||||
(`SHACRAFT_ADMISSION_TICKET`, `SHACRAFT_ADMISSION_PRIVATE_KEY`). Never put
|
authenticated with the pinned Tauri/minisign public key before JSON parsing.
|
||||||
them in global environment, argv/argfiles, settings, logs or IPC. A fresh
|
Each exact version/platform artifact also requires that signature, SHA-256
|
||||||
launch obtains a fresh ticket; there is no shared launcher secret.
|
and size. Never reuse the ShaCraft mod-manifest key or accept IPC URLs/keys.
|
||||||
- Once admission is enforced on Aeronautics, its server mod verifies the
|
- Linux self-update requires the original ordinary AppImage file and the frozen
|
||||||
challenge/proof before world entry, and server-side whitelist enforcement
|
Tauri `APPDIR` bound to the running `usr/bin/shacraft-launcher`. Extracted or
|
||||||
remains a final access boundary. Replace LoginSystem only as part of the
|
inherited AppImage context is manual-only; never replace a bare binary.
|
||||||
validated `aoc` rollout; other servers are unaffected. Old launchers without
|
- `update_guard.rs` retains one launcher-instance OS lock, drains native writes
|
||||||
admission proof will be rejected after enforcement. This authenticates an
|
through `operations::Lifecycle`, and checks the existing detached-game lease.
|
||||||
account's permission, not the integrity of an unmodified launcher binary.
|
`launcher-state/pending-update.json` survives installer handoff; only startup
|
||||||
- Application updates are a separate trust domain from Minecraft profiles.
|
of its exact target version acknowledges it. Never clear it on a timer.
|
||||||
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
|
||||||
|
|
||||||
@@ -112,9 +81,8 @@ 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` — process-local install/account permits owned by workers.
|
- `operations.rs` — lifecycle and install/account permits owned by workers.
|
||||||
The account permit covers ticket issuance through Java spawn, preventing
|
Launch must use the authenticated ShaCraft nickname; no settings fallback.
|
||||||
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,
|
||||||
@@ -123,22 +91,17 @@ 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` — runs NeoForge's official installer headlessly.
|
- `neoforge.rs` / `neoforge_repair.rs` — verified official installer, isolated
|
||||||
|
processor rebuild, checked embedded JSON and generated-output receipts.
|
||||||
|
Never hash legacy generated artifacts as an initial trusted baseline.
|
||||||
- `runtime.rs` — Java 21 auto-provisioning via Eclipse Adoptium.
|
- `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
|
||||||
nickname claim/admission API; legacy link commands remain for compatibility.
|
verified nickname-link API.
|
||||||
- `admission.rs` — ephemeral key generation, strict ticket response validation,
|
- `launch.rs` — builds and spawns the actual `java` process.
|
||||||
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
|
||||||
@@ -146,9 +109,15 @@ 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.
|
||||||
- `.github/workflows/build.yml` — main-push/manual cross-platform CI artifacts;
|
Ubuntu 22.04 dependency steps use only the runner main Ubuntu source list;
|
||||||
updater signing is explicitly disabled there. Local release signing and
|
keep APT signature/hash checks and fail on index errors. Vendor PPAs are not needed.
|
||||||
atomic feed publication are documented in `docs/launcher-updates.md`.
|
- `.github/workflows/build.yml` — four-platform CI packages with disposable test
|
||||||
|
signing keys, explicitly unusable as production releases.
|
||||||
|
- Release workflows and `scripts/release.py` implement protected draft → publish
|
||||||
|
gates; see `docs/updater-release.md`. Never publish assets piecemeal, reuse CI
|
||||||
|
test keys, or confuse updater signatures with OS signing/notarization.
|
||||||
|
- `src-tauri/updater-public-key.txt` is the public production trust root.
|
||||||
|
Private updater keys remain outside all repositories; never commit them.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -167,14 +136,6 @@ 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
|
||||||
|
|
||||||
@@ -184,93 +145,3 @@ socket to `135.106.154.86:25567`. See architecture and server rollout records.
|
|||||||
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,113 +17,32 @@
|
|||||||
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 не заменяют эти прогоны.
|
||||||
- [ ] Windows Authenticode / macOS signing-notarization и проверка установщиков.
|
- [x] Native самообновление, русский UX, pinned updater key и защищённый release pipeline.
|
||||||
|
- [x] Локальные AppImage/deb собраны на Ubuntu 26.04 с CI test key; подписи и
|
||||||
|
форматы проверены. Настоящий AppImage прошёл изолированный startup/lock/marker
|
||||||
|
smoke. Это не проверка установки, самообновления или совместимости Ubuntu 22.04.
|
||||||
|
- [ ] Настроить защищённое GitHub environment/production secrets, опубликовать первый
|
||||||
|
updater-релиз и проверить реальную замену приложения на каждой ОС. Подпись ОС и
|
||||||
|
Apple notarization — отдельные настройки; CI test keys не предназначены игрокам.
|
||||||
- [ ] Реальная отмена загрузок, журнал с редактированием токенов и retry UX.
|
- [ ] Реальная отмена загрузок, журнал с редактированием токенов и retry UX.
|
||||||
- [ ] Выбор каталога профиля и безопасный reset только managed-файлов.
|
- [ ] Выбор каталога профиля и безопасный reset только managed-файлов.
|
||||||
- [ ] Keychain-хранилище refresh token; cross-process exclusion при необходимости.
|
- [ ] Keychain-хранилище сессии ShaCraft; OS-lock и lease игры уже реализованы.
|
||||||
- [ ] Динамический каталог и новости; реальный Aeronautics онлайн уже
|
- [ ] Динамический каталог и новости; реальный Aeronautics онлайн уже
|
||||||
загружается через фиксированный display-only API. Не имитировать данные.
|
загружается через фиксированный display-only API. Не имитировать данные.
|
||||||
|
|
||||||
## Вход по одноразовому разрешению: внедрение 2026-09-10
|
## Исправления по handoff (исходники, до выкладки)
|
||||||
|
|
||||||
- [x] Новый native claim закрепляет свободный ник за аккаунтом без входа в игру.
|
- [x] Один signed snapshot и общий межпроцессный lock на Play/Repair.
|
||||||
Старые имена резервируются backend и переносятся администратором.
|
- [x] Inventory, journal/recovery, retirement старых неизменённых managed-файлов.
|
||||||
- [x] После установки Rust создаёт Ed25519 ключ через OS CSPRNG и запрашивает
|
- [x] Явный перенос выбранных legacy-модов в резервную копию.
|
||||||
ticket на фиксированном ShaCraft API. Ник для запуска берётся из ticket;
|
- [x] NeoForge clean rebuild + provenance receipt; непустая порча обнаруживается.
|
||||||
локальные настройки не могут его заменить.
|
- [x] Отдельный первый вход с серверным grant и одноразовым proof.
|
||||||
- [x] Ticket и PKCS#8 private key передаются только окружением дочерней Java;
|
- [x] RAM retry сохраняет намерение; неверный JAVA_HOME не скрывает подходящий PATH.
|
||||||
session сайта остаётся в native. В argv, argfile, settings, логах и IPC
|
- [x] Версии в интерфейсе берутся из проверенного manifest.
|
||||||
секретов нет. Account permit держится до spawn.
|
- [ ] Совместная выкладка backend/Game Bridge/подписанного payload и нового лаунчера.
|
||||||
- [x] Ошибки и результаты привязки показаны в диалоге; двойной клик не создаёт
|
- [ ] Изолированная игровая проверка LoginSystem + hold + proof, затем beta по ОС.
|
||||||
параллельных запросов, старого 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
|
|
||||||
требует нового запуска игры из лаунчера; автоматического обновления нет.
|
|
||||||
|
|
||||||
## Самообновление лаунчера 0.1.3: реализация 2026-09-10
|
Сопутствующий серверный код содержит read-only status, nonce proof с legacy
|
||||||
|
migration и durable grant/revoke outbox. Оплата и доставка whitelist — разные
|
||||||
- [x] Отдельный канал обновления приложения на фиксированном HTTPS endpoint.
|
состояния. Реферальные правила остаются отдельной задачей серверного PLAN;
|
||||||
Выделенный публичный ключ проверяет подпись 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,15 +3,19 @@
|
|||||||
Кроссплатформенный Tauri 2 лаунчер для [ShaCraft](https://shacraft.ru/):
|
Кроссплатформенный Tauri 2 лаунчер для [ShaCraft](https://shacraft.ru/):
|
||||||
React/TypeScript интерфейс, Rust — файлы, сеть и запуск процессов.
|
React/TypeScript интерфейс, Rust — файлы, сеть и запуск процессов.
|
||||||
|
|
||||||
Реализованы отдельные профили Aeronautics (Minecraft 1.21.1, NeoForge, Java 21)
|
Реализованы подписанная синхронизация Aeronautics, проверка/восстановление
|
||||||
и Minigames (Minecraft 26.2, Fabric, Java 25), подписанная синхронизация файлов,
|
модов и конфигурации, Java discovery/provisioning, bootstrap Minecraft и
|
||||||
проверка/восстановление модов и конфигурации, установка игры и Java, настройки
|
NeoForge, настройки памяти, обработка установки/запуска/выхода. Проверка
|
||||||
памяти, обработка установки/запуска/выхода. Minigames опубликован в 0.1.6;
|
сборки теперь восстанавливает и игровые файлы; старые неизменённые managed-моды
|
||||||
проверки и результаты публикации — в [записи релиза](docs/release-0.1.6.md).
|
убираются в резервную копию, неизвестные моды разбираются явно пользователем.
|
||||||
|
|
||||||
Вход выполняется через аккаунт ShaCraft — тот же, что на сайте. Игровой ник
|
Вход выполняется через аккаунт ShaCraft — тот же, что на сайте. Игровой ник
|
||||||
общий для обоих профилей и берётся только из подтверждённой привязки `aoc`.
|
берётся только из подтверждённой привязки Aeronautics, а не из редактируемых
|
||||||
Тикет входа привязан к выбранному серверу; локальные настройки не выбирают личность. Пароли не сохраняются; сессию можно отозвать.
|
локальных настроек. Пароли не сохраняются; сессию можно отозвать.
|
||||||
|
Для первого входа без привязки добавлено отдельное действие в настройках
|
||||||
|
аккаунта: установка → серверное разрешение → LoginSystem → одноразовая команда
|
||||||
|
подтверждения. Оно требует совместной выкладки backend, Game Bridge и
|
||||||
|
подписанного модпака; наличие исходников не означает публикацию этого сценария.
|
||||||
Microsoft OAuth-модуль сохранён отдельно, но не используется текущим
|
Microsoft OAuth-модуль сохранён отдельно, но не используется текущим
|
||||||
сценарием запуска; для его активации потребуются client ID и API approval.
|
сценарием запуска; для его активации потребуются client ID и API approval.
|
||||||
|
|
||||||
@@ -39,30 +43,14 @@ 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) — инструкции для следующего разработчика/агента.
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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.
@@ -1,8 +0,0 @@
|
|||||||
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
|
|
||||||
Vendored
-248
@@ -1,248 +0,0 @@
|
|||||||
#!/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
@@ -1,82 +0,0 @@
|
|||||||
@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%
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
pluginManagement {
|
|
||||||
repositories {
|
|
||||||
maven { url = 'https://maven.fabricmc.net/' }
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rootProject.name = 'shacraft-admission-client'
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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]"; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"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": "*" }
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+19
-15
@@ -91,20 +91,24 @@ 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)
|
|
||||||
|
|
||||||
Metadata comes only from `https://meta.fabricmc.net/v2/versions/loader/` for
|
## Generated NeoForge artifacts and Java selection
|
||||||
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.
|
|
||||||
|
|
||||||
Minecraft 26.2's official version metadata requires Java 25. `runtime.rs`
|
NeoForge 21.1.248's actual installer does not publish processor-output hashes.
|
||||||
already provisions a separate Adoptium Java 25 runtime without changing the
|
The launcher therefore validates the installer SHA-256, extracts its expected
|
||||||
Aeronautics Java 21 runtime. Client companion mods and Fabric API are separately
|
version JSON and a scoped processor recipe, verifies the vanilla input against
|
||||||
approved ShaCraft managed files in the signed Minigames profile.
|
Mojang, and runs that installer in a fresh staging tree. Generated JARs must
|
||||||
|
be readable ZIPs with valid entries. A receipt commits the generated hashes,
|
||||||
|
installer digest and recipe identity only after successful verified promotion.
|
||||||
|
This is provenance from a trusted computation, not independent publisher
|
||||||
|
checksums for generated files. Later corruption invalidates the receipt and
|
||||||
|
triggers clean reconstruction; a nonempty old file is never enough. Interrupted
|
||||||
|
promotion has no valid receipt and is repaired on the next preparation.
|
||||||
|
Only recipe-scoped generated artifacts are promoted; user profile files are
|
||||||
|
outside this tree. Staging can remain after an abrupt kill and is never trusted
|
||||||
|
as an installation. The receipt currently lives under the shared game cache.
|
||||||
|
|
||||||
|
Java discovery evaluates JAVA_HOME and PATH candidates independently for the
|
||||||
|
exact required major. A wrong-major JAVA_HOME no longer hides a valid PATH
|
||||||
|
installation. Only if no matching candidate exists is a managed runtime
|
||||||
|
provisioned. The UI reads required versions from verified profile metadata.
|
||||||
|
|||||||
+167
-282
@@ -2,43 +2,37 @@
|
|||||||
|
|
||||||
## Current capability
|
## Current capability
|
||||||
|
|
||||||
The launcher persists local settings, synchronises profile mod/config
|
The launcher persists local settings, synchronises Aeronautics 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 current
|
signs in with the same local ShaCraft account used on the website. The game
|
||||||
admission implementation claims a free nickname for that account and requests
|
identity for ordinary Play is derived only from that account's verified Aeronautics nickname;
|
||||||
a one-use permission immediately before launching Java. The game identity
|
the legacy editable nickname setting is not trusted at launch.
|
||||||
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.
|
||||||
|
|
||||||
Version 0.1.3 adds signed application updates, separate from modpack sync.
|
The launcher also checks signed GitHub releases for its own updates. AppImage,
|
||||||
Version 0.1.4 adds installed deb updates with system administrator confirmation.
|
Windows x64 installers and native Intel/Apple Silicon macOS app bundles use
|
||||||
The first AppImage upgrade from 0.1.2 and deb upgrade from 0.1.3 are manual.
|
explicit install-and-restart; Debian packages use manual package management.
|
||||||
Windows/macOS 0.1.5 packages are published; actual desktop installation tests remain pending.
|
Production publication and OS signing/notarization require operator setup;
|
||||||
Not yet implemented: a user-selectable profile directory and a "reset managed
|
see `updater-release.md`. Version 0.1.1 has no updater and must be upgraded
|
||||||
files only" recovery action. OS code signing/notarization is separate from the
|
manually once. User-selectable profile directories and managed-only reset
|
||||||
updater signatures and is not certified by this implementation.
|
remain unimplemented.
|
||||||
|
|
||||||
## Data flow
|
## Data flow
|
||||||
|
|
||||||
Managed files, game installation and account admission meet at Java spawn:
|
One verified ShaCraft snapshot is fetched inside the installation lock for
|
||||||
|
each Play/Repair operation. Every stage, the displayed completion metadata and
|
||||||
|
the spawn use that snapshot; a newly published manifest is used next time.
|
||||||
|
Two independent trust pipelines feed one launch:
|
||||||
|
|
||||||
```text
|
```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)
|
||||||
-> temporary download, SHA-256 verification, atomic replacement (profile.rs)
|
-> inventory reconciliation, verified staging, durable apply journal (profile.rs)
|
||||||
|
|
||||||
Game itself (never controlled by the manifest above)
|
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)
|
||||||
@@ -46,14 +40,9 @@ 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)
|
||||||
Admission (fixed ShaCraft account API; never controlled by a manifest)
|
-> deterministic offline UUID for the linked nickname (session.rs)
|
||||||
native website session -> direct free-nickname claim or admin migration
|
-> java process spawned with the merged classpath/args (launch.rs)
|
||||||
-> 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/
|
||||||
@@ -63,8 +52,7 @@ 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. The admission private key and ticket are ephemeral native
|
written to disk. None of these should be assumed to
|
||||||
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
|
||||||
@@ -79,81 +67,18 @@ 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 canonical `aoc` nickname returned with the admission
|
- Launch identity: the most recently verified `aoc` nickname returned by the
|
||||||
ticket. Local nickname edits cannot select an identity.
|
authenticated account API. 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. Windows Authenticode/macOS signing-notarization and cross-platform release
|
3. Production release credentials/protected environments and OS beta validation.
|
||||||
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.
|
||||||
|
|
||||||
@@ -170,7 +95,10 @@ 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.
|
||||||
These are process-local guards, not cross-process locks or cancellation.
|
An additional OS file lock in `installation_lock.rs` covers all profiles and
|
||||||
|
the shared game installation across processes. It remains held by the child
|
||||||
|
watcher; a durable PID/start-time lease also protects a game that outlives its
|
||||||
|
launcher. This is exclusion, not cancellation.
|
||||||
`storage.rs` provides unique temporary files and atomic replacement; Unix
|
`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
|
||||||
@@ -178,201 +106,158 @@ 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.
|
covers native policy and storage. Push/PR CI repeats checks on Linux. Ubuntu 22.04
|
||||||
The package workflow runs on main pushes or manually and builds Windows
|
CI installs desktop dependencies from the runner main distro source list only,
|
||||||
x64, Linux x64 and both macOS architectures with named artifacts.
|
without indexing unrelated vendor repositories. APT signature/hash checks remain
|
||||||
CI packages are unsigned build artifacts. Signed updater publication is a
|
mandatory, and any distro index failure stops installation.
|
||||||
separate local operator step. Native cold-install and launch tests are required
|
The package workflow builds and checks Windows x64, Linux x64 and both macOS
|
||||||
before calling a platform release-ready.
|
architectures with disposable test signing keys. These artifacts cannot be
|
||||||
|
published as production updater releases. Separate protected workflows assemble
|
||||||
The local admission checkpoint passed 64 Rust tests (5 live tests ignored),
|
a complete signed release as a draft, re-verify its assets and only then publish.
|
||||||
22 UI unit tests, TypeScript/Vite build and a Linux x86-64 release build with
|
Native cold-install, real self-update and launch tests are required before
|
||||||
`npm run tauri:build -- --no-bundle`. Six browser scenarios with mocked Tauri
|
calling a platform release-ready. CI package builds do not prove those flows.
|
||||||
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.
|
|
||||||
|
|
||||||
|
|
||||||
## Cross-platform release 0.1.5 (2026-09-10)
|
## Reconciliation and recovery
|
||||||
|
|
||||||
Published Windows x64 EXE/MSI, macOS aarch64 and x86_64 DMG/app.tar.gz,
|
`profiles/.<profile-id>.shacraft-state/` is outside the payload root. It stores
|
||||||
and Linux x64 AppImage/DEB on https://shacraft.ru/help#launcher. The signed
|
`inventory.json`, `pending.json`, transaction staging/backups/receipts and
|
||||||
stable feed includes all platforms plus the legacy/exact Linux aliases.
|
explicit legacy-mod backups. The inventory records only files the launcher
|
||||||
CI source b43fc610c43a9ec9f5f3ffce601de9604670ff8a, successful Actions run
|
actually wrote, with size/hash/policy and signed snapshot digest. An existing
|
||||||
https://github.com/emil28092005/shacraft-launcher/actions/runs/34512683651.
|
file that already matches is usable, but is not silently claimed as owned.
|
||||||
An initial non-Linux borrow/move compilation error in updater target selection
|
Seed files are never owned for later removal.
|
||||||
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.
|
All required downloads are staged and verified before apply. A write-ahead
|
||||||
|
journal records old/new states. Interrupted apply resumes by checking hashes;
|
||||||
|
readiness stays false while a journal, stale owned files or conflicts remain.
|
||||||
|
Old owned files are moved to a backup only when their current contents still
|
||||||
|
match the recorded version. Changed files remain conflicts; unrelated files
|
||||||
|
and player worlds, screenshots, options and resourcepacks are preserved.
|
||||||
|
A manifest is not authority to overwrite an unknown colliding local file.
|
||||||
|
|
||||||
|
“Разобрать моды” lists legacy/changed JAR candidates without selecting any.
|
||||||
|
The user explicitly chooses paths; native code rechecks each selected hash
|
||||||
|
and safe path before moving it to a recoverable backup with a receipt. Unknown
|
||||||
|
extra mods are informational, not automatically removed or adopted. Readiness
|
||||||
|
means the declared pack files were checked; it does not certify arbitrary
|
||||||
|
additional user mods. Afterwards rerun Repair or Play.
|
||||||
|
|
||||||
|
`game/cache/neoforge-receipts/<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.
|
||||||
|
|
||||||
|
|
||||||
## Admission client menu 0.1.1 (2026-09-11)
|
## Launcher self-update boundary (0.2.0)
|
||||||
|
|
||||||
The signed Aeronautics payload now contains admission mod 0.1.1 at the existing
|
`updater/protocol.rs` pins `github.com/emil28092005/shacraft-launcher/releases`.
|
||||||
managed path `mods/shacraft-admission-0.1.0.jar` to prevent duplicate mod IDs on
|
The exact raw `latest.json` response is verified using its detached minisign
|
||||||
upgrade. SHA-256: `faa9ae13cb0f2d93c03dae26ab36ae20d3fb6b66c89c09254b16808d6b183f89`.
|
signature and the committed updater public key before versions, URLs or notes
|
||||||
From the title screen (and vanilla safety acknowledgement), Multiplayer connects
|
are parsed. This key is independent of ShaCraft's Ed25519 mod manifest. Signed
|
||||||
to fixed `135.106.154.86:25567`. Cancel/errors return to TitleScreen; transitions
|
metadata binds a stable version and tag to an exact set of four platform and
|
||||||
from other screens do not auto-connect. Client-only registration, protocol 1 and
|
four manual package descriptors; every descriptor has an exact repository/tag/
|
||||||
one-use admission remain unchanged. Running game server was not restarted.
|
filename, size, SHA-256 and Tauri signature. HTTPS redirects are restricted to
|
||||||
Linux Java 21 build and 8 mod tests pass. An opt-in native live test verifies
|
that repository and GitHub's release asset CDN. Stable downgrades, unknown
|
||||||
signed-manifest retrieval and download/repair/restoration of the admission jar
|
platforms, missing signatures and incomplete metadata fail closed.
|
||||||
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 alongside Aeronautics (2026-09-13)
|
Only native commands check, download, install and open the fixed releases page.
|
||||||
|
The webview supplies no URLs, public keys, executable arguments, release version
|
||||||
|
or arbitrary file path. No generic updater plugin permission is granted to it.
|
||||||
|
The packaged native architecture selects the artifact: Windows preserves MSI
|
||||||
|
versus NSIS, macOS preserves Intel versus Apple Silicon, Linux only replaces an
|
||||||
|
AppImage. The original non-symlink AppImage must have the expected native header,
|
||||||
|
and the frozen Tauri `APPDIR` must contain the actual running executable at
|
||||||
|
`usr/bin/shacraft-launcher`. Extracted binaries and inherited context from another
|
||||||
|
AppImage require manual installation. A Debian installation requires the user's
|
||||||
|
package manager.
|
||||||
|
|
||||||
The new native allowlist adds profile `minigames` at
|
Checks run once per application UI lifecycle and on explicit request. They do
|
||||||
`https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest` and
|
not install automatically. The settings drawer shows installed/available
|
||||||
its display-only count at `https://shacraft.ru/api/online/minigames`.
|
versions, plain-text notes, progress, actionable errors and retry. The explicit
|
||||||
Aeronautics remains a separate profile and keeps its previous managed files.
|
install button includes restart. UI session recovery codes, unsaved/failed
|
||||||
Minigames uses Minecraft 26.2, Fabric Loader 0.19.5, Java 25 and its own
|
settings and account/game operations inhibit that action; native permits are
|
||||||
`profiles/minigames` game directory. The signed payload supplies Fabric API
|
the final authority for concurrent writes. Preferences, sessions and game data
|
||||||
0.160.0+26.2 and `mods/shacraft-admission-client-0.1.0.jar`; it never supplies
|
live outside the executable and are not migrated or erased by the updater.
|
||||||
Paper, the server plugins, maps, credentials or game-download URLs.
|
|
||||||
|
|
||||||
`fabric.rs` obtains an exact parent/loader profile from fixed Fabric metadata,
|
`launcher-state/instance.lock` is a process-lifetime OS lock, preventing an idle
|
||||||
checks its identity and KnotClient entry point, and converts bounded Maven
|
second cooperating launcher from retaining old code during replacement. All
|
||||||
library entries into the existing verified library contract. Each artifact
|
native account/settings/game writes hold shared lifecycle permits. Replacement
|
||||||
URL is constructed from a validated coordinate below fixed Fabric Maven;
|
holds the exclusive permit and `installation-state/writer.lock`, which also
|
||||||
metadata-supplied alternative origins are rejected. SHA-1 and size come from
|
checks a game process's durable PID/start-time lease. A normal window close is
|
||||||
Fabric metadata, or the same Maven's hash sidecar and HEAD for the loader jar.
|
inhibited during download/install. Worker permits survive a dropped IPC future.
|
||||||
Java provisioning already accepts exactly Java 25. Automatic installation on
|
Before invoking the platform installer, `pending-update.json` records current
|
||||||
a platform still requires a real cold-install check on that platform.
|
and target versions. The Windows plugin hands off and exits; the marker remains.
|
||||||
|
Only startup of the exact target version clears it. Old versions and indeterminate
|
||||||
|
installer failures block native mutations and direct the user to manual recovery.
|
||||||
|
A corrupt marker opens recovery diagnostics and latches the mutation/launch ban
|
||||||
|
until application restart, even if that file is removed while the UI is open.
|
||||||
|
No guessed installer timeout
|
||||||
|
releases the gate. This cannot retroactively make old 0.1.1 binaries cooperate
|
||||||
|
with these locks.
|
||||||
|
|
||||||
Both profiles claim/display the canonical `aoc` nickname. Only the native
|
First metadata/signature and artifact reads are bounded. Tauri updater 2.11.0
|
||||||
profile mapping determines ticket `server_id` (`aoc` or `minigames`), and the
|
requires its own second check to construct a private installer context; that
|
||||||
response must match that exact server. The backend is responsible for shared
|
check uses the fixed version endpoint, HTTPS host policy and a 30-second timeout,
|
||||||
access checks at both issuance and redemption. Existing account sessions and
|
and its parsed metadata must equal the previously authenticated document.
|
||||||
settings need no migration. No copied subscription/whitelist grant is trusted.
|
The plugin's secondary response has no byte-limit API, leaving a memory-use
|
||||||
|
risk if that trusted release endpoint serves an unexpectedly large response.
|
||||||
Minigames adds native `--quickPlayMultiplayer 135.106.154.86:25568` at launch.
|
Immediately before install, native code re-verifies artifact size/hash/signature;
|
||||||
The client-only Fabric companion validates the actual socket target and signs
|
`Update::install` alone does not perform signature verification.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
> 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/).
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
## 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.
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
# Launcher updater releases
|
||||||
|
|
||||||
|
The updater has its own signing key and fixed repository:
|
||||||
|
`emil28092005/shacraft-launcher`. It is independent of the ShaCraft modpack
|
||||||
|
manifest key. The committed public key is `src-tauri/updater-public-key.txt`;
|
||||||
|
the native client and release validation pin that key. An unset/placeholder key
|
||||||
|
cannot produce a release.
|
||||||
|
|
||||||
|
## Packages and signed metadata
|
||||||
|
|
||||||
|
The four build targets each produce two distributable files, with canonical names:
|
||||||
|
|
||||||
|
- Windows x64: `shacraft-launcher_VERSION_windows-x86_64-setup.exe` (NSIS)
|
||||||
|
and `shacraft-launcher_VERSION_windows-x86_64.msi`.
|
||||||
|
- Linux x64: `shacraft-launcher_VERSION_linux-x86_64.AppImage` and
|
||||||
|
`shacraft-launcher_VERSION_linux-x86_64.deb`.
|
||||||
|
- macOS arm64: `shacraft-launcher_VERSION_darwin-aarch64.dmg` and
|
||||||
|
`shacraft-launcher_VERSION_darwin-aarch64.app.tar.gz`.
|
||||||
|
- macOS x64: `shacraft-launcher_VERSION_darwin-x86_64.dmg` and
|
||||||
|
`shacraft-launcher_VERSION_darwin-x86_64.app.tar.gz`.
|
||||||
|
|
||||||
|
Every package has a detached `.sig` from the Tauri signer. AppImage, NSIS, MSI
|
||||||
|
and app tar signatures must already exist after `tauri build`; absence is an
|
||||||
|
error. The collection step signs deb and DMG explicitly. Renaming a package does
|
||||||
|
not alter its bytes or signature. The macOS app archive contains the `.app`
|
||||||
|
bundle; the DMG remains the manual installation package. These artifact formats
|
||||||
|
follow [Tauri's updater documentation](https://v2.tauri.app/plugin/updater/).
|
||||||
|
|
||||||
|
`latest.json` contains exactly `schemaVersion` (integer 1), `version`, `tag`,
|
||||||
|
`notes`, `pub_date`, `platforms` and `manualPackages`. `version` is stable
|
||||||
|
`MAJOR.MINOR.PATCH`; `tag` is exactly `vVERSION`. Windows MSI limits apply:
|
||||||
|
major/minor at most 255 and patch at most 65535. `pub_date` uses UTC
|
||||||
|
`YYYY-MM-DDTHH:MM:SSZ`, derived from the tagged source commit.
|
||||||
|
|
||||||
|
`platforms` has exactly `windows-x86_64`, `linux-x86_64`, `darwin-aarch64` and
|
||||||
|
`darwin-x86_64`. Their primary packages are NSIS, AppImage and the two app archives.
|
||||||
|
`manualPackages` has exactly `windows-x86_64-msi`, `linux-x86_64-deb`,
|
||||||
|
`darwin-aarch64-dmg` and `darwin-x86_64-dmg`. Despite the section name, the native
|
||||||
|
client also selects the MSI descriptor when updating an MSI installation.
|
||||||
|
|
||||||
|
Each descriptor has exactly `url`, `signature`, `sha256` and `size`. URLs are
|
||||||
|
bound to the exact version/tag and canonical filename under
|
||||||
|
`https://github.com/emil28092005/shacraft-launcher/releases/download/vVERSION/`.
|
||||||
|
SHA-256 is lowercase hexadecimal; size is a positive integer at most 1 GiB.
|
||||||
|
Metadata is at most 32 KiB, notes at most 4096 UTF-8 bytes and signatures at most
|
||||||
|
2048 characters.
|
||||||
|
|
||||||
|
The release script writes deterministic UTF-8 JSON with sorted keys, two-space
|
||||||
|
indentation and a trailing newline. It signs those exact bytes into
|
||||||
|
`latest.json.sig` using the same updater key. The client verifies this signature
|
||||||
|
before parsing: an artifact signature alone cannot bind a version or download
|
||||||
|
URL. The signed metadata binds the complete platform set, versions, filenames,
|
||||||
|
hashes, sizes and package signatures together.
|
||||||
|
|
||||||
|
`scripts/release-verifier` uses `minisign-verify` 0.2.5 and the same verification
|
||||||
|
sequence as the [Tauri updater 2.11 implementation](https://github.com/tauri-apps/plugins-workspace/blob/v2/plugins/updater/src/updater.rs):
|
||||||
|
base64-decode the Tauri public key/signature containers, decode the minisign
|
||||||
|
objects, then verify the exact file bytes including the trusted comment. Missing,
|
||||||
|
nonempty-but-invalid and mismatched-key signatures all fail. The utility does
|
||||||
|
not execute installers or replace applications.
|
||||||
|
|
||||||
|
## CI and protected draft creation
|
||||||
|
|
||||||
|
`check.yml` runs the usual UI/Rust checks plus release tests. `build.yml` builds
|
||||||
|
all four targets with fresh disposable test keys. It has read-only repository
|
||||||
|
permissions and receives no production signing secret. Transient build config
|
||||||
|
and `SHACRAFT_UPDATER_TEST_BUILD=1` select the test key; no committed pin changes.
|
||||||
|
Artifacts are labelled `CI-NOT-FOR-RELEASE-*`, include `CI_NOT_FOR_RELEASE.txt`,
|
||||||
|
and cannot pass release validation. Test keys are never uploaded.
|
||||||
|
|
||||||
|
Before a release, an operator must configure two existing GitHub environments:
|
||||||
|
`launcher-release` and `launcher-release-publish`. Both require a reviewer other
|
||||||
|
than the dispatcher (`prevent_self_review=true`) and permit protected branches
|
||||||
|
only. `main` must be protected. This is a two-person operation: the dispatcher
|
||||||
|
cannot approve their own deployment. The workflow gate checks these settings
|
||||||
|
through GitHub's [environment API](https://docs.github.com/en/rest/deployments/environments)
|
||||||
|
before exposing an environment name to later jobs. Missing environments or
|
||||||
|
insufficient API permissions stop the workflow; it never creates an unprotected
|
||||||
|
replacement or bypasses the check.
|
||||||
|
|
||||||
|
Set `SHACRAFT_UPDATER_PUBLIC_KEY` as an environment/repository variable, equal to
|
||||||
|
the complete committed base64 public key. `launcher-release` alone needs the
|
||||||
|
`TAURI_SIGNING_PRIVATE_KEY` secret (official Tauri encoded key contents) and,
|
||||||
|
for an encrypted key, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`. The password may be
|
||||||
|
omitted/empty for an unencrypted key; signing is noninteractive. The publish
|
||||||
|
environment needs only the public variable, never the private key.
|
||||||
|
|
||||||
|
The private key must live outside Git, build artifacts and web roots. Keep the
|
||||||
|
key directory owner-only (0700), its key file owner-only (0600), and retain a
|
||||||
|
separate secure backup outside Git. Do not print the key, upload it as an
|
||||||
|
artifact or keep a purported encryption password beside it. Losing the key
|
||||||
|
breaks updates for installed clients; changing the public key is a coordinated
|
||||||
|
migration, not routine key regeneration.
|
||||||
|
|
||||||
|
To prepare a release:
|
||||||
|
|
||||||
|
1. Merge the reviewed source/version change. Package JSON, npm lock, Cargo
|
||||||
|
package and Tauri config versions must match. Create the exact `vVERSION`
|
||||||
|
tag on a commit belonging to protected `main`; the workflow never creates or
|
||||||
|
moves a tag.
|
||||||
|
2. Manually run **Prepare signed release draft** from `main` with that version,
|
||||||
|
tag and release notes. Pass the independent environment approval. Every
|
||||||
|
matrix runner checks the tag/versions and signs a challenge to prove the
|
||||||
|
supplied private key matches the committed public key before packaging.
|
||||||
|
3. All four jobs must finish. Only then does the final job assemble all eight
|
||||||
|
packages, all eight signatures and signed metadata. It verifies this complete
|
||||||
|
18-file set before creating a GitHub draft, uploads it, downloads it again
|
||||||
|
and re-verifies the draft. A partial upload remains a draft and cannot pass
|
||||||
|
publication validation. An existing draft is never silently overwritten.
|
||||||
|
|
||||||
|
Before any candidate source runs, trusted workflow Git commands prove that the
|
||||||
|
tag commit belongs to protected main. Later jobs check out the validated immutable
|
||||||
|
SHA, not the movable tag. Checkout credentials are removed, and the publication
|
||||||
|
token exists only in the final explicit publishing step.
|
||||||
|
|
||||||
|
The tag/release workflow is manual; pushing a tag does not publish an update.
|
||||||
|
The scripts do not configure GitHub environments, keys or secrets. Repository
|
||||||
|
access alone is not authorization to run the publication workflow.
|
||||||
|
|
||||||
|
## Explicit final publication
|
||||||
|
|
||||||
|
After reviewing the draft and completing platform acceptance, an operator runs
|
||||||
|
**Publish verified release (operator only)** from `main`, supplies version/tag
|
||||||
|
and the exact confirmation `publish vVERSION`, then obtains the separate
|
||||||
|
publish-environment approval. This job has no signing key. It checks the source
|
||||||
|
versions/tag, current committed public pin, environment policy and monotonic
|
||||||
|
stable release version; downloads every draft asset and checks all signatures,
|
||||||
|
hashes, sizes, names and signed metadata again. It also checks asset IDs/sizes/
|
||||||
|
digests did not change during validation. Only this step changes `draft` to false
|
||||||
|
and marks the release latest. Failures leave the draft unpublished.
|
||||||
|
|
||||||
|
GitHub `latest/download/latest.json` and its detached signature can temporarily
|
||||||
|
refer to different releases during CDN propagation. The client must reject that
|
||||||
|
mismatch and retry; it must never accept unsigned metadata as a fallback.
|
||||||
|
|
||||||
|
## Acceptance and bootstrap
|
||||||
|
|
||||||
|
Updater signatures authenticate update bytes. They are **not** Windows
|
||||||
|
Authenticode signatures, Apple Developer ID signatures or Apple notarization.
|
||||||
|
This pipeline does not provision those certificates or claim that SmartScreen
|
||||||
|
or Gatekeeper will trust a manually downloaded package. Any OS signing/notarization
|
||||||
|
step must finish before updater signing and metadata hashing. Never modify a
|
||||||
|
package after its updater signature is created.
|
||||||
|
|
||||||
|
Version 0.1.1 has no updater: users must install the first updater-enabled release
|
||||||
|
manually. Keeping application identifiers and installer families stable is
|
||||||
|
necessary, but unit tests do not establish upgrade compatibility. Before final
|
||||||
|
publication, validate actual old-to-new NSIS and MSI installations separately,
|
||||||
|
including per-user/elevated installation and preservation of account/settings/
|
||||||
|
Minecraft data. The native updater preserves the MSI/NSIS family.
|
||||||
|
|
||||||
|
Validate both macOS architectures on real supported systems, including writable
|
||||||
|
and protected application locations and process restart. Linux automatic
|
||||||
|
replacement is for an AppImage running from a writable AppImage location; a deb
|
||||||
|
installation shows availability and opens the fixed official releases page;
|
||||||
|
installation then uses the normal OS package installer. Non-AppImage or unwritable installations must not be treated
|
||||||
|
as successfully self-updated. Verify interrupted download, wrong signature,
|
||||||
|
current/no-update, relaunch and concurrent game/install behavior on each platform.
|
||||||
|
|
||||||
|
Local release tests use temporary synthetic payloads and newly generated
|
||||||
|
throwaway keys. They cover real signature verification, bit flips, wrong keys,
|
||||||
|
metadata substitution, exact artifact sets, duplicate fields, version/tag
|
||||||
|
bindings, CI promotion rejection and missing environment protection. They do
|
||||||
|
not prove any Windows/macOS installer ran, a live GitHub release was published,
|
||||||
|
or an end user's application updated successfully.
|
||||||
|
|
||||||
|
## Installed package migration and recovery
|
||||||
|
|
||||||
|
The settings drawer reports the installed native package family. Automatic
|
||||||
|
updates preserve it:
|
||||||
|
|
||||||
|
- Windows NSIS x64 → NSIS x64, retaining the existing per-user scope and saved
|
||||||
|
installation location. The signed package must contain an EXE, not MSI bytes.
|
||||||
|
- Windows MSI x64 → MSI x64, retaining the per-machine installer family. The
|
||||||
|
pinned UpgradeCode `2058b1df-56a1-51ef-bd48-d296479cd59a` is the exact value
|
||||||
|
Tauri CLI 2.11.4 derived for the existing 0.1.1 product name; ProductCode can
|
||||||
|
change for a major upgrade. Administrator permission may be required.
|
||||||
|
- MSI ↔ NSIS, simultaneous installations of both, renamed products, changed
|
||||||
|
scopes and manually moved Windows installations have no automatic migration
|
||||||
|
promise. Close the old application and use an explicit manual installer path;
|
||||||
|
verify installed-app registrations and account/settings preservation in beta.
|
||||||
|
- macOS Intel → Intel app archive, Apple Silicon → Apple Silicon app archive.
|
||||||
|
DMG is the bootstrap/manual distribution. Move the app out of a mounted DMG
|
||||||
|
into an appropriate Applications directory before use. A protected destination
|
||||||
|
may require OS permission or a manual replacement; an installation error never
|
||||||
|
counts as a completed update.
|
||||||
|
- Linux x64 AppImage → x64 AppImage at its current writable location. The original
|
||||||
|
ordinary, non-symlink AppImage must exist, and the frozen Tauri `APPDIR` must
|
||||||
|
match the running `usr/bin/shacraft-launcher`. Extracted AppDirs and an inherited
|
||||||
|
environment from another AppImage use manual installation. Debian
|
||||||
|
packages, RPM, bare development binaries and unsupported architectures never
|
||||||
|
enter the self-replacement path. Install a new deb through the system package
|
||||||
|
manager; the launcher does not run privileged package-manager commands.
|
||||||
|
|
||||||
|
These paths follow the locked [Tauri MSI implementation](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle/windows/msi/mod.rs),
|
||||||
|
[NSIS installer template](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle/windows/templates/installer.nsi)
|
||||||
|
and [updater implementation](https://github.com/tauri-apps/plugins-workspace/blob/updater-v2.11.0/plugins/updater/src/updater.rs).
|
||||||
|
Source inspection and CI packaging do not replace real installation acceptance.
|
||||||
|
|
||||||
|
The updater writes `launcher-state/pending-update.json` immediately before the
|
||||||
|
platform installer. Once handed off, cancellation, power loss and installer
|
||||||
|
errors can be ambiguous. Windows does not expose an installer PID/completion
|
||||||
|
result through the plugin, so the launcher never guesses that installation has
|
||||||
|
finished after a timeout. The exact target version acknowledges the marker on
|
||||||
|
startup. Until then, recovery mode blocks native game, account, settings and
|
||||||
|
update mutations. Corrupt marker bytes also open this diagnostic mode, never
|
||||||
|
normal operation. Its restriction stays latched even if the file is removed
|
||||||
|
while that process is open. Unix marker creation/acknowledgment syncs the parent
|
||||||
|
directory as well as file contents.
|
||||||
|
|
||||||
|
For an interrupted update:
|
||||||
|
|
||||||
|
1. Close Minecraft, all launcher instances and any installer; reboot if their
|
||||||
|
termination cannot be established. Do not delete active OS lock files.
|
||||||
|
2. Install the marker's expected target version manually from the fixed official
|
||||||
|
releases page, preserving the package family above. Replace only the app or
|
||||||
|
package, keeping the application data directory. Starting that target version
|
||||||
|
acknowledges a valid marker automatically.
|
||||||
|
3. If the marker is corrupt, or the target cannot be used, first manually restore
|
||||||
|
a complete official package of the intended supported version. With every
|
||||||
|
launcher/game/installer closed, rename only `launcher-state/pending-update.json`
|
||||||
|
to a backup outside `launcher-state`, then launch again. This is an operator
|
||||||
|
recovery after a complete package repair, not a shortcut around an active
|
||||||
|
installer. Keep the backup for diagnosis. Never remove settings, sessions,
|
||||||
|
profiles, game files, `instance.lock`, `writer.lock` or a live game lease.
|
||||||
|
|
||||||
|
Default application data roots (or the configured XDG data home on Linux):
|
||||||
|
|
||||||
|
- Windows: `%APPDATA%\ru.shacraft.launcher`.
|
||||||
|
- macOS: `~/Library/Application Support/ru.shacraft.launcher`.
|
||||||
|
- Linux: `~/.local/share/ru.shacraft.launcher`.
|
||||||
|
|
||||||
|
Network/hash/signature failures before installer entry leave the app intact and
|
||||||
|
permit an explicit fresh check/retry. They do not create an install handoff or
|
||||||
|
require marker recovery. A separate “game still running” error must be resolved
|
||||||
|
by closing the game, not removing its lease.
|
||||||
|
|
||||||
|
|
||||||
|
## Package architecture checks
|
||||||
|
|
||||||
|
Before collection/signing, scripts inspect package bytes without running an
|
||||||
|
installer. AppImage must have an ELF64 little-endian AMD64 header and the Type 2
|
||||||
|
`AI\x02` marker. The deb ar/control archive must declare `Architecture: amd64`
|
||||||
|
and the release version. Each macOS app archive must have exactly one
|
||||||
|
`Info.plist`, the same release version, and a regular main executable with a
|
||||||
|
thin Mach-O64 CPU type matching its matrix target. DMG checks validate the UDIF
|
||||||
|
container trailer; they do not mount or inspect the DMG filesystem.
|
||||||
|
|
||||||
|
On the Windows build runner, the built launcher must be AMD64 PE32+. NSIS uses
|
||||||
|
an x86 installer stub even for an x64 application: the checker accepts that
|
||||||
|
wrapper, uses the runner's 7-Zip to list/extract only the named launcher to
|
||||||
|
stdout, then requires an x64 payload identical to the built main executable after the
|
||||||
|
exact Tauri bundle-type stamp described below.
|
||||||
|
It never executes NSIS. Missing/unsupported 7-Zip inspection fails the build;
|
||||||
|
there is no silent architecture-check fallback. MSI is checked for a compound
|
||||||
|
file header and read using WindowsInstaller COM with `MSIDBOPEN_READONLY`:
|
||||||
|
Template Summary must say `x64` and ProductVersion must match. The MSI check also
|
||||||
|
checks the built main executable, but does not extract MSI's embedded cabinet or
|
||||||
|
prove that cabinet's payload matches the build. Real installer acceptance is
|
||||||
|
still required. These distinctions follow the [PE format](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format)
|
||||||
|
and [64-bit MSI package requirements](https://learn.microsoft.com/en-us/windows/win32/msi/using-64-bit-windows-installer-packages).
|
||||||
|
|
||||||
|
The locked [Tauri CLI 2.11.4 bundler](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle.rs)
|
||||||
|
replaces the first complete `__TAURI_BUNDLE_TYPE_VAR_UNK` token with
|
||||||
|
`__TAURI_BUNDLE_TYPE_VAR_NSS` for NSIS (`MSI` for MSI), packages that binary, then
|
||||||
|
restores the original unpatched/unsigned executable on disk after each bundle.
|
||||||
|
The NSIS comparator constructs precisely that one replacement in a copy of the
|
||||||
|
built bytes and compares the entire extracted payload. It does not mask any PE
|
||||||
|
section, checksum, certificate table, padding or other bytes. A missing marker,
|
||||||
|
wrong family stamp or any unrelated byte change fails. Both original and
|
||||||
|
extracted executables must still be AMD64 PE32+.
|
||||||
|
|
||||||
|
Authenticode is currently unconfigured. Adding it can also change the PE checksum
|
||||||
|
and append a certificate table; this comparison intentionally fails until a
|
||||||
|
separate verified signed-baseline procedure is implemented. Do not broaden the
|
||||||
|
comparison to ignore all certificate/checksum differences merely to pass CI.
|
||||||
|
|
||||||
|
Portable format checks repeat when validating signed release assets. Full NSIS
|
||||||
|
payload/MSI COM checks run only during collection on the Windows runner; the
|
||||||
|
Linux draft/publish verifier rechecks their container headers and signatures.
|
||||||
|
Header/metadata inspection is not a runtime, architecture-emulation or installer
|
||||||
|
migration test. Synthetic header fixtures exercise these checks; they are never
|
||||||
|
installed or executed.
|
||||||
|
|
||||||
|
|
||||||
|
## Local package verification, 2026-09-09
|
||||||
|
|
||||||
|
Application source `696c6b0` was built on Ubuntu 26.04 x86_64 using a disposable
|
||||||
|
CI updater key and the visible test-build flag. Actual AppImage and deb packages
|
||||||
|
passed the same collection checks used by the workflow: expected container,
|
||||||
|
architecture/version where represented, and cryptographic updater signatures.
|
||||||
|
The manual-package collector explicitly signs deb/DMG even when a CLI version
|
||||||
|
also emits their signatures. No production package or release was signed.
|
||||||
|
|
||||||
|
The actual AppImage was started with `--appimage-extract-and-run`, isolated XDG
|
||||||
|
roots and a pending update targeting a different version. The native executable
|
||||||
|
ran inside its own `APPDIR/usr/bin/shacraft-launcher`, referenced the expected
|
||||||
|
`APPIMAGE`, held the instance OS lock and preserved the pending marker during
|
||||||
|
startup and after termination. This checks native startup and the runtime binding;
|
||||||
|
it does not establish visual/IPC behavior, installation, self-replacement or a
|
||||||
|
successful updater restart. The deb was inspected, not installed.
|
||||||
|
|
||||||
|
This local Ubuntu 26.04 build is not a compatibility result for Ubuntu 22.04 or
|
||||||
|
other distributions. The four-platform GitHub matrix, real Windows/macOS
|
||||||
|
installations, old-version migration and end-to-end update acceptance remain
|
||||||
|
pending. Production signing credentials and release environments are not
|
||||||
|
configured on GitHub, and no release has been published.
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
{
|
|
||||||
"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."
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"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.1.7",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "shacraft-launcher-ui",
|
"name": "shacraft-launcher-ui",
|
||||||
"version": "0.1.7",
|
"version": "0.2.0",
|
||||||
"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.1.7",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
#!/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()
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/target/
|
||||||
Generated
+23
@@ -0,0 +1,23 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.22.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minisign-verify"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shacraft-release-verifier"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"minisign-verify",
|
||||||
|
]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "shacraft-release-verifier"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
base64 = "=0.22.1"
|
||||||
|
minisign-verify = "=0.2.5"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//! Uses precisely the Tauri updater 2.11 signature verification primitive.
|
||||||
|
//! Upstream: plugins/updater/src/updater.rs::verify_signature (MIT/Apache-2.0).
|
||||||
|
use base64::Engine;
|
||||||
|
use minisign_verify::{PublicKey, Signature};
|
||||||
|
use std::{env, fs, process::ExitCode};
|
||||||
|
|
||||||
|
fn verify() -> Result<(), Box<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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Assemble/verify release assets. No network or publication in this module."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import tomllib
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import release_formats
|
||||||
|
|
||||||
|
REPOSITORY = "emil28092005/shacraft-launcher"
|
||||||
|
ORIGIN = f"https://github.com/{REPOSITORY}/releases/download"
|
||||||
|
PLATFORMS = {
|
||||||
|
"windows-x86_64": ("nsis/*.exe", "-setup.exe"),
|
||||||
|
"linux-x86_64": ("appimage/*.AppImage", ".AppImage"),
|
||||||
|
"darwin-aarch64": ("macos/*.app.tar.gz", ".app.tar.gz"),
|
||||||
|
"darwin-x86_64": ("macos/*.app.tar.gz", ".app.tar.gz"),
|
||||||
|
}
|
||||||
|
MANUAL = {
|
||||||
|
"windows-x86_64-msi": ("windows-x86_64", "msi/*.msi", ".msi"),
|
||||||
|
"linux-x86_64-deb": ("linux-x86_64", "deb/*.deb", ".deb"),
|
||||||
|
"darwin-aarch64-dmg": ("darwin-aarch64", "dmg/*.dmg", ".dmg"),
|
||||||
|
"darwin-x86_64-dmg": ("darwin-x86_64", "dmg/*.dmg", ".dmg"),
|
||||||
|
}
|
||||||
|
FIELDS = {"url", "signature", "sha256", "size"}
|
||||||
|
MAX_SIZE = 1024**3
|
||||||
|
|
||||||
|
|
||||||
|
def require(condition, message):
|
||||||
|
if not condition:
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def version_tag(version, tag):
|
||||||
|
require(
|
||||||
|
re.fullmatch(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", version),
|
||||||
|
"release version must be stable MAJOR.MINOR.PATCH",
|
||||||
|
)
|
||||||
|
major, minor, patch = map(int, version.split("."))
|
||||||
|
require(major <= 255 and minor <= 255 and patch <= 65535, "version exceeds MSI limits")
|
||||||
|
require(tag == f"v{version}", "tag/version mismatch")
|
||||||
|
|
||||||
|
|
||||||
|
def filename(version, platform, suffix):
|
||||||
|
return f"shacraft-launcher_{version}_{platform}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def expected_names(version):
|
||||||
|
return {filename(version, key, value[1]) for key, value in PLATFORMS.items()} | {
|
||||||
|
filename(version, platform, suffix) for platform, _, suffix in MANUAL.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(data):
|
||||||
|
return (json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def unique_object(pairs):
|
||||||
|
result = {}
|
||||||
|
for key, value in pairs:
|
||||||
|
require(key not in result, "duplicate JSON field")
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path):
|
||||||
|
return json.loads(path.read_bytes(), object_pairs_hook=unique_object)
|
||||||
|
|
||||||
|
|
||||||
|
def public_key():
|
||||||
|
value = os.environ.get("SHACRAFT_UPDATER_PUBLIC_KEY", "").strip()
|
||||||
|
require(bool(value), "SHACRAFT_UPDATER_PUBLIC_KEY is missing")
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(value, validate=True).decode("utf-8")
|
||||||
|
lines = decoded.splitlines()
|
||||||
|
raw = base64.b64decode(lines[1], validate=True)
|
||||||
|
require(
|
||||||
|
lines[0].startswith("untrusted comment:") and len(raw) == 42 and raw[:2] == b"Ed",
|
||||||
|
"invalid Tauri public key",
|
||||||
|
)
|
||||||
|
except (ValueError, IndexError, UnicodeError) as error:
|
||||||
|
raise ValueError("invalid Tauri public key") from error
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def verifier_path(root):
|
||||||
|
return os.environ.get(
|
||||||
|
"RELEASE_VERIFIER",
|
||||||
|
str(
|
||||||
|
root
|
||||||
|
/ "scripts/release-verifier/target/release"
|
||||||
|
/ ("shacraft-release-verifier.exe" if os.name == "nt" else "shacraft-release-verifier")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_signature(root, data, signature):
|
||||||
|
with tempfile.TemporaryDirectory(prefix="shacraft-verify-") as temporary:
|
||||||
|
key = Path(temporary) / "public-key"
|
||||||
|
key.write_text(public_key(), encoding="utf-8")
|
||||||
|
result = subprocess.run(
|
||||||
|
[verifier_path(root), str(key), str(data), str(signature)],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=120,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
require(result.returncode == 0, f"invalid updater signature: {data.name}")
|
||||||
|
|
||||||
|
|
||||||
|
def signer(root, data):
|
||||||
|
private = os.environ.get("TAURI_SIGNING_PRIVATE_KEY", "")
|
||||||
|
require(bool(private.strip()), "TAURI_SIGNING_PRIVATE_KEY is missing")
|
||||||
|
environment = dict(os.environ)
|
||||||
|
# An omitted password means an unencrypted key. Never allow a CI prompt;
|
||||||
|
# encrypted keys without the correct password fail in the signer.
|
||||||
|
environment.setdefault("TAURI_SIGNING_PRIVATE_KEY_PASSWORD", "")
|
||||||
|
# Builds accept a key path; signer sign accepts the encoded key contents.
|
||||||
|
try:
|
||||||
|
is_key_path = len(private) < 4096 and "\n" not in private and Path(private).is_file()
|
||||||
|
except OSError:
|
||||||
|
is_key_path = False
|
||||||
|
if is_key_path:
|
||||||
|
environment["TAURI_SIGNING_PRIVATE_KEY"] = Path(private).read_text().strip()
|
||||||
|
environment.pop("TAURI_SIGNING_PRIVATE_KEY_PATH", None)
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", str(root / "node_modules/@tauri-apps/cli/tauri.js"), "signer", "sign", str(data)],
|
||||||
|
env=environment,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=120,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
require(result.returncode == 0, "Tauri signing failed (check protected signing credentials)")
|
||||||
|
|
||||||
|
|
||||||
|
def preflight(root, version, tag, check_git=False, signing=False):
|
||||||
|
version_tag(version, tag)
|
||||||
|
versions = [
|
||||||
|
read_json(root / "package.json")["version"],
|
||||||
|
read_json(root / "package-lock.json")["version"],
|
||||||
|
read_json(root / "package-lock.json")["packages"][""]["version"],
|
||||||
|
read_json(root / "src-tauri/tauri.conf.json")["version"],
|
||||||
|
tomllib.loads((root / "src-tauri/Cargo.toml").read_text())["package"]["version"],
|
||||||
|
]
|
||||||
|
require(all(value == version for value in versions), "source versions disagree with release")
|
||||||
|
if check_git:
|
||||||
|
|
||||||
|
def git(*args):
|
||||||
|
return subprocess.check_output(["git", *args], cwd=root, text=True).strip()
|
||||||
|
|
||||||
|
require(
|
||||||
|
git("rev-parse", "HEAD") == git("rev-parse", f"refs/tags/{tag}^{{commit}}"),
|
||||||
|
"checkout does not match existing release tag",
|
||||||
|
)
|
||||||
|
subprocess.run(
|
||||||
|
["git", "merge-base", "--is-ancestor", "HEAD", "origin/main"], cwd=root, check=True
|
||||||
|
)
|
||||||
|
if signing:
|
||||||
|
require(os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") != "1", "CI test keys cannot release")
|
||||||
|
pinned = (root / "src-tauri/updater-public-key.txt").read_text().strip()
|
||||||
|
require(public_key() == pinned, "release public key differs from committed updater key")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="shacraft-key-check-") as temporary:
|
||||||
|
challenge = Path(temporary) / "key-check"
|
||||||
|
challenge.write_bytes(f"ShaCraft release key check {tag}\n".encode())
|
||||||
|
signer(root, challenge)
|
||||||
|
verify_signature(root, challenge, Path(str(challenge) + ".sig"))
|
||||||
|
|
||||||
|
|
||||||
|
def write_build_config(destination):
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
destination.write_bytes(
|
||||||
|
canonical(
|
||||||
|
{
|
||||||
|
"bundle": {"createUpdaterArtifacts": True},
|
||||||
|
"plugins": {"updater": {"pubkey": public_key()}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ci_key(root, directory):
|
||||||
|
require(not os.environ.get("TAURI_SIGNING_PRIVATE_KEY"), "CI must not receive production key")
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
key = directory / "DISPOSABLE-CI-ONLY.key"
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"node",
|
||||||
|
str(root / "node_modules/@tauri-apps/cli/tauri.js"),
|
||||||
|
"signer",
|
||||||
|
"generate",
|
||||||
|
"--ci",
|
||||||
|
"--password",
|
||||||
|
"",
|
||||||
|
"--write-keys",
|
||||||
|
str(key),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=120,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
require(result.returncode == 0, "disposable CI key generation failed")
|
||||||
|
key.chmod(0o600)
|
||||||
|
values = {
|
||||||
|
"TAURI_SIGNING_PRIVATE_KEY": str(key),
|
||||||
|
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD": "",
|
||||||
|
"SHACRAFT_UPDATER_PUBLIC_KEY": Path(str(key) + ".pub").read_text().strip(),
|
||||||
|
"SHACRAFT_UPDATER_TEST_BUILD": "1",
|
||||||
|
}
|
||||||
|
os.environ.update(values)
|
||||||
|
write_build_config(directory / "updater-build.json")
|
||||||
|
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as stream:
|
||||||
|
for name, value in values.items():
|
||||||
|
require("\n" not in value and "\r" not in value, "invalid CI environment value")
|
||||||
|
stream.write(f"{name}={value}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def collect(root, bundle, destination, platform, version):
|
||||||
|
version_tag(version, f"v{version}")
|
||||||
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
require(not list(destination.iterdir()), "collection destination must be empty")
|
||||||
|
entries = [(PLATFORMS[platform][0], PLATFORMS[platform][1], True)]
|
||||||
|
entries += [
|
||||||
|
(glob, suffix, suffix == ".msi") for key, glob, suffix in MANUAL.values() if key == platform
|
||||||
|
]
|
||||||
|
for glob, suffix, required_signature in entries:
|
||||||
|
matches = list(bundle.glob(glob))
|
||||||
|
require(
|
||||||
|
len(matches) == 1 and matches[0].is_file() and not matches[0].is_symlink(),
|
||||||
|
f"expected exactly one {platform} {glob}",
|
||||||
|
)
|
||||||
|
source = matches[0]
|
||||||
|
main = bundle.parent / "shacraft-launcher.exe" if platform == "windows-x86_64" else None
|
||||||
|
release_formats.validate(source, platform, suffix, version, main)
|
||||||
|
target = destination / filename(version, platform, suffix)
|
||||||
|
shutil.copyfile(source, target)
|
||||||
|
source_signature = Path(str(source) + ".sig")
|
||||||
|
signature = Path(str(target) + ".sig")
|
||||||
|
if required_signature:
|
||||||
|
require(
|
||||||
|
source_signature.is_file() and not source_signature.is_symlink(),
|
||||||
|
f"missing generated updater signature: {source.name}",
|
||||||
|
)
|
||||||
|
shutil.copyfile(source_signature, signature)
|
||||||
|
else:
|
||||||
|
signer(root, target) # Sign manual packages explicitly, independent of bundler output.
|
||||||
|
verify_signature(root, target, signature)
|
||||||
|
if os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") == "1":
|
||||||
|
(destination / "CI_NOT_FOR_RELEASE.txt").write_text(
|
||||||
|
"DISPOSABLE TEST KEY. These CI artifacts are not deployable releases.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def descriptor(root, directory, version, tag, platform, suffix):
|
||||||
|
name = filename(version, platform, suffix)
|
||||||
|
path, sig = directory / name, directory / (name + ".sig")
|
||||||
|
require(
|
||||||
|
path.is_file() and not path.is_symlink() and sig.is_file() and not sig.is_symlink(),
|
||||||
|
f"missing or unsafe release asset: {name}",
|
||||||
|
)
|
||||||
|
size = path.stat().st_size
|
||||||
|
require(0 < size <= MAX_SIZE, "invalid package size")
|
||||||
|
verify_signature(root, path, sig)
|
||||||
|
release_formats.validate(path, platform, suffix, version)
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
digest = hashlib.file_digest(stream, "sha256").hexdigest()
|
||||||
|
signature = sig.read_text(encoding="utf-8").strip()
|
||||||
|
require(0 < len(signature) <= 2048, "invalid signature length")
|
||||||
|
return {"url": f"{ORIGIN}/{tag}/{name}", "signature": signature, "sha256": digest, "size": size}
|
||||||
|
|
||||||
|
|
||||||
|
def metadata(root, directory, version, tag, notes, date):
|
||||||
|
version_tag(version, tag)
|
||||||
|
require(len(notes.encode()) <= 4096, "release notes too long")
|
||||||
|
require(
|
||||||
|
re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", date), "expected UTC publication date"
|
||||||
|
)
|
||||||
|
datetime.fromisoformat(date.replace("Z", "+00:00"))
|
||||||
|
return {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"version": version,
|
||||||
|
"tag": tag,
|
||||||
|
"notes": notes,
|
||||||
|
"pub_date": date,
|
||||||
|
"platforms": {
|
||||||
|
key: descriptor(root, directory, version, tag, key, suffix)
|
||||||
|
for key, (_, suffix) in PLATFORMS.items()
|
||||||
|
},
|
||||||
|
"manualPackages": {
|
||||||
|
key: descriptor(root, directory, version, tag, platform, suffix)
|
||||||
|
for key, (platform, _, suffix) in MANUAL.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def verify_release(root, directory, version, tag):
|
||||||
|
version_tag(version, tag)
|
||||||
|
names = expected_names(version)
|
||||||
|
expected = names | {name + ".sig" for name in names} | {"latest.json", "latest.json.sig"}
|
||||||
|
require(
|
||||||
|
{path.name for path in directory.iterdir()} == expected,
|
||||||
|
"release asset set is incomplete or unexpected",
|
||||||
|
)
|
||||||
|
path = directory / "latest.json"
|
||||||
|
require(path.stat().st_size <= 32768, "metadata exceeds limit")
|
||||||
|
require(
|
||||||
|
not path.is_symlink() and not (directory / "latest.json.sig").is_symlink(),
|
||||||
|
"unsafe metadata",
|
||||||
|
)
|
||||||
|
verify_signature(
|
||||||
|
root, path, directory / "latest.json.sig"
|
||||||
|
) # Verify exact bytes BEFORE parsing.
|
||||||
|
actual = read_json(path)
|
||||||
|
require(
|
||||||
|
isinstance(actual, dict)
|
||||||
|
and set(actual)
|
||||||
|
== {"schemaVersion", "version", "tag", "notes", "pub_date", "platforms", "manualPackages"},
|
||||||
|
"unexpected metadata fields",
|
||||||
|
)
|
||||||
|
require(
|
||||||
|
type(actual["schemaVersion"]) is int and actual["schemaVersion"] == 1,
|
||||||
|
"unsupported metadata schema",
|
||||||
|
)
|
||||||
|
require(actual["version"] == version and actual["tag"] == tag, "signed version/tag mismatch")
|
||||||
|
require(
|
||||||
|
isinstance(actual["notes"], str) and isinstance(actual["pub_date"], str),
|
||||||
|
"invalid metadata text",
|
||||||
|
)
|
||||||
|
for section, keys in (("platforms", PLATFORMS), ("manualPackages", MANUAL)):
|
||||||
|
require(
|
||||||
|
isinstance(actual[section], dict) and set(actual[section]) == set(keys),
|
||||||
|
"unexpected platform/package set",
|
||||||
|
)
|
||||||
|
for item in actual[section].values():
|
||||||
|
require(isinstance(item, dict) and set(item) == FIELDS, "unexpected descriptor fields")
|
||||||
|
require(
|
||||||
|
type(item["size"]) is int and 0 < item["size"] <= MAX_SIZE,
|
||||||
|
"invalid descriptor size",
|
||||||
|
)
|
||||||
|
require(
|
||||||
|
all(isinstance(item[name], str) for name in ("url", "signature", "sha256")),
|
||||||
|
"invalid descriptor text",
|
||||||
|
)
|
||||||
|
expected_metadata = metadata(root, directory, version, tag, actual["notes"], actual["pub_date"])
|
||||||
|
require(
|
||||||
|
actual == expected_metadata,
|
||||||
|
"metadata does not match exact platforms, URLs, hashes, sizes or signatures",
|
||||||
|
)
|
||||||
|
require(path.read_bytes() == canonical(expected_metadata), "metadata is not canonical")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
check = sub.add_parser("preflight")
|
||||||
|
check.add_argument("--version", required=True)
|
||||||
|
check.add_argument("--tag", required=True)
|
||||||
|
check.add_argument("--check-git", action="store_true")
|
||||||
|
check.add_argument("--signing", action="store_true")
|
||||||
|
check.add_argument("--config", type=Path)
|
||||||
|
ci = sub.add_parser("ci-key")
|
||||||
|
ci.add_argument("--directory", required=True, type=Path)
|
||||||
|
gather = sub.add_parser("collect")
|
||||||
|
gather.add_argument("--bundle", required=True, type=Path)
|
||||||
|
gather.add_argument("--directory", required=True, type=Path)
|
||||||
|
gather.add_argument("--platform", required=True, choices=PLATFORMS)
|
||||||
|
gather.add_argument("--version", required=True)
|
||||||
|
for name in ("metadata", "verify"):
|
||||||
|
item = sub.add_parser(name)
|
||||||
|
item.add_argument("--directory", required=True, type=Path)
|
||||||
|
item.add_argument("--version", required=True)
|
||||||
|
item.add_argument("--tag", required=True)
|
||||||
|
if name == "metadata":
|
||||||
|
item.add_argument("--date", required=True)
|
||||||
|
item.add_argument("--notes", default="")
|
||||||
|
args = parser.parse_args()
|
||||||
|
root = args.root.resolve()
|
||||||
|
if args.command == "preflight":
|
||||||
|
preflight(root, args.version, args.tag, args.check_git, args.signing)
|
||||||
|
if args.config:
|
||||||
|
write_build_config(args.config)
|
||||||
|
elif args.command == "ci-key":
|
||||||
|
ci_key(root, args.directory)
|
||||||
|
elif args.command == "collect":
|
||||||
|
collect(root, args.bundle, args.directory, args.platform, args.version)
|
||||||
|
elif args.command == "metadata":
|
||||||
|
require(os.environ.get("SHACRAFT_UPDATER_TEST_BUILD") != "1", "CI artifacts cannot release")
|
||||||
|
data = metadata(root, args.directory, args.version, args.tag, args.notes, args.date)
|
||||||
|
path = args.directory / "latest.json"
|
||||||
|
require(
|
||||||
|
not path.exists() and not (args.directory / "latest.json.sig").exists(),
|
||||||
|
"metadata already exists",
|
||||||
|
)
|
||||||
|
path.write_bytes(canonical(data))
|
||||||
|
signer(root, path)
|
||||||
|
verify_release(root, args.directory, args.version, args.tag)
|
||||||
|
else:
|
||||||
|
verify_release(root, args.directory, args.version, args.tag)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (ValueError, OSError, subprocess.SubprocessError, KeyError, TypeError) as error:
|
||||||
|
print(f"Release validation failed: {error}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""No network: reject unsafe draft listings before invoking GitHub download."""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import release
|
||||||
|
import release_github
|
||||||
|
|
||||||
|
|
||||||
|
class DraftAssetGateTests(unittest.TestCase):
|
||||||
|
def draft(self):
|
||||||
|
packages = release.expected_names("0.3.0")
|
||||||
|
names = packages | {name + ".sig" for name in packages} | {"latest.json", "latest.json.sig"}
|
||||||
|
self.assertEqual(len(names), 18)
|
||||||
|
return {
|
||||||
|
"id": 123, "tag_name": "v0.3.0", "draft": True, "prerelease": False,
|
||||||
|
"assets": [{"name": name, "id": index, "state": "uploaded", "size": 1, "digest": None}
|
||||||
|
for index, name in enumerate(sorted(names), 1)],
|
||||||
|
}
|
||||||
|
|
||||||
|
def assert_no_download(self, data):
|
||||||
|
with (patch.object(release_github, "api", return_value=data),
|
||||||
|
patch.object(release_github, "gh") as github,
|
||||||
|
patch.object(release, "verify_release") as verify):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0")
|
||||||
|
github.assert_not_called()
|
||||||
|
verify.assert_not_called()
|
||||||
|
|
||||||
|
def fake_download(self, listing):
|
||||||
|
def download(*args):
|
||||||
|
self.assertEqual(args[:3], ("release", "download", "v0.3.0"))
|
||||||
|
destination = Path(args[args.index("--dir") + 1])
|
||||||
|
for asset in listing["assets"]:
|
||||||
|
(destination / asset["name"]).write_bytes(b"x" * asset["size"])
|
||||||
|
return download
|
||||||
|
|
||||||
|
def test_exact_complete_listing_still_downloads_and_verifies_actual_contents(self):
|
||||||
|
listing = self.draft()
|
||||||
|
with (patch.object(release_github, "api", side_effect=[listing, listing]) as api,
|
||||||
|
patch.object(release_github, "gh", side_effect=self.fake_download(listing)) as github,
|
||||||
|
patch.object(release, "verify_release") as verify):
|
||||||
|
self.assertEqual(release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0"), listing)
|
||||||
|
github.assert_called_once()
|
||||||
|
verify.assert_called_once()
|
||||||
|
self.assertEqual(api.call_count, 2)
|
||||||
|
|
||||||
|
def test_unexpected_traversal_absolute_and_excessive_names_cannot_download(self):
|
||||||
|
for name in ["extra.exe", "../latest.json", r"..\latest.json", "/tmp/latest.json",
|
||||||
|
"folder/latest.json", "latest.JSON", "x" * 4096]:
|
||||||
|
with self.subTest(name=name[:50]):
|
||||||
|
listing = self.draft()
|
||||||
|
listing["assets"][0]["name"] = name
|
||||||
|
self.assert_no_download(listing)
|
||||||
|
listing = self.draft()
|
||||||
|
listing["assets"].append({"name": "extra.txt", "id": 100, "state": "uploaded", "size": 1})
|
||||||
|
self.assert_no_download(listing)
|
||||||
|
|
||||||
|
def test_missing_duplicate_and_unfinished_files_cannot_download(self):
|
||||||
|
for index in range(18):
|
||||||
|
with self.subTest(missing=index):
|
||||||
|
listing = self.draft()
|
||||||
|
listing["assets"].pop(index)
|
||||||
|
self.assert_no_download(listing)
|
||||||
|
listing = self.draft()
|
||||||
|
listing["assets"].append(copy.deepcopy(listing["assets"][0]))
|
||||||
|
self.assert_no_download(listing)
|
||||||
|
listing = self.draft()
|
||||||
|
listing["assets"][0]["state"] = "new"
|
||||||
|
self.assert_no_download(listing)
|
||||||
|
|
||||||
|
def test_every_asset_size_must_be_a_positive_integer_within_its_limit(self):
|
||||||
|
listing = self.draft()
|
||||||
|
for index, asset in enumerate(listing["assets"]):
|
||||||
|
limit = 32768 if asset["name"] == "latest.json" else 8192 if asset["name"].endswith(".sig") else 1024**3
|
||||||
|
for size in [0, -1, True, False, 1.0, "1", None, limit + 1]:
|
||||||
|
with self.subTest(name=asset["name"], size=size):
|
||||||
|
altered = copy.deepcopy(listing)
|
||||||
|
altered["assets"][index]["size"] = size
|
||||||
|
self.assert_no_download(altered)
|
||||||
|
|
||||||
|
def test_declared_size_boundaries_are_accepted_without_allocating_large_files(self):
|
||||||
|
listing = self.draft()
|
||||||
|
for asset in listing["assets"]:
|
||||||
|
asset["size"] = 32768 if asset["name"] == "latest.json" else 8192 if asset["name"].endswith(".sig") else 1024**3
|
||||||
|
with (patch.object(release_github, "api", return_value=listing),
|
||||||
|
patch.object(release_github, "gh", side_effect=RuntimeError("download boundary reached")) as github):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "download boundary reached"):
|
||||||
|
release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0")
|
||||||
|
github.assert_called_once()
|
||||||
|
|
||||||
|
def test_valid_listing_does_not_bypass_signature_failure_or_remote_race_checks(self):
|
||||||
|
listing = self.draft()
|
||||||
|
with (patch.object(release_github, "api", return_value=listing) as api,
|
||||||
|
patch.object(release_github, "gh", side_effect=self.fake_download(listing)) as github,
|
||||||
|
patch.object(release, "verify_release", side_effect=ValueError("invalid updater signature")) as verify):
|
||||||
|
with self.assertRaisesRegex(ValueError, "invalid updater signature"):
|
||||||
|
release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0")
|
||||||
|
github.assert_called_once()
|
||||||
|
verify.assert_called_once()
|
||||||
|
self.assertEqual(api.call_count, 1)
|
||||||
|
changed = copy.deepcopy(listing)
|
||||||
|
changed["assets"][0]["id"] += 100
|
||||||
|
with (patch.object(release_github, "api", side_effect=[listing, changed]),
|
||||||
|
patch.object(release_github, "gh", side_effect=self.fake_download(listing)),
|
||||||
|
patch.object(release, "verify_release") as verify):
|
||||||
|
with self.assertRaisesRegex(ValueError, "changed during verification"):
|
||||||
|
release_github.verify_draft(Path("/unused"), "0.3.0", "v0.3.0")
|
||||||
|
verify.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""Read-only package checks. Never execute or install a package under inspection."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import plistlib
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
|
||||||
|
def require(condition, message):
|
||||||
|
if not condition:
|
||||||
|
raise ValueError(f"Package validation failed: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def pe_machine(data):
|
||||||
|
require(len(data) >= 64 and data[:2] == b"MZ", "invalid PE DOS header")
|
||||||
|
offset = struct.unpack_from("<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)
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Explicit workflow-only GitHub release operations, with fail-closed gates."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import release
|
||||||
|
|
||||||
|
ENVIRONMENTS = {"launcher-release", "launcher-release-publish"}
|
||||||
|
|
||||||
|
|
||||||
|
def gh(*args, missing=False):
|
||||||
|
result = subprocess.run(["gh", *args], capture_output=True, text=True, timeout=900, check=False)
|
||||||
|
if result.returncode:
|
||||||
|
try:
|
||||||
|
body = json.loads(result.stdout)
|
||||||
|
except ValueError:
|
||||||
|
body = {}
|
||||||
|
if missing and str(body.get("status")) == "404":
|
||||||
|
return None
|
||||||
|
raise ValueError("GitHub operation failed; no permission or validation bypass is allowed")
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def api(path, missing=False):
|
||||||
|
value = gh("api", f"repos/{release.REPOSITORY}/{path}", missing=missing)
|
||||||
|
return None if value is None else json.loads(value)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_environment(data):
|
||||||
|
release.require(isinstance(data, dict), "protected environment is absent")
|
||||||
|
policy = data.get("deployment_branch_policy") or {}
|
||||||
|
release.require(
|
||||||
|
policy.get("protected_branches") is True and policy.get("custom_branch_policies") is False,
|
||||||
|
"release environment must allow protected branches only",
|
||||||
|
)
|
||||||
|
rules = data.get("protection_rules") or []
|
||||||
|
reviewers = next((rule for rule in rules if rule.get("type") == "required_reviewers"), {})
|
||||||
|
release.require(
|
||||||
|
reviewers.get("prevent_self_review") is True, "environment must prevent self review"
|
||||||
|
)
|
||||||
|
allowed = reviewers.get("reviewers") or []
|
||||||
|
release.require(
|
||||||
|
any(
|
||||||
|
item.get("type") in {"User", "Team"}
|
||||||
|
and type((item.get("reviewer") or {}).get("id")) is int
|
||||||
|
and item["reviewer"]["id"] > 0
|
||||||
|
for item in allowed
|
||||||
|
),
|
||||||
|
"release environment requires an independent reviewer",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_guard():
|
||||||
|
release.require(
|
||||||
|
os.environ.get("GITHUB_REPOSITORY") == release.REPOSITORY, "wrong release repository"
|
||||||
|
)
|
||||||
|
release.require(
|
||||||
|
os.environ.get("GITHUB_EVENT_NAME") == "workflow_dispatch",
|
||||||
|
"release must be dispatched manually",
|
||||||
|
)
|
||||||
|
release.require(
|
||||||
|
os.environ.get("GITHUB_REF") == "refs/heads/main", "release workflow must run from main"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def gate(environment):
|
||||||
|
workflow_guard()
|
||||||
|
release.require(environment in ENVIRONMENTS, "unexpected release environment")
|
||||||
|
release.require(
|
||||||
|
api("branches/main").get("protected") is True, "main must be a protected branch"
|
||||||
|
)
|
||||||
|
validate_environment(api(f"environments/{environment}", missing=True))
|
||||||
|
# Jobs consume this output only after validation. Never reference a missing
|
||||||
|
# environment directly: GitHub would create it without protection rules.
|
||||||
|
if os.environ.get("GITHUB_OUTPUT"):
|
||||||
|
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as stream:
|
||||||
|
stream.write(f"environment={environment}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def asset_snapshot(data):
|
||||||
|
release.require(
|
||||||
|
data.get("draft") is True and data.get("prerelease") is False,
|
||||||
|
"expected a stable DRAFT release",
|
||||||
|
)
|
||||||
|
assets = data.get("assets") or []
|
||||||
|
result = {}
|
||||||
|
for asset in assets:
|
||||||
|
name = asset["name"]
|
||||||
|
release.require(
|
||||||
|
name not in result and asset.get("state") == "uploaded",
|
||||||
|
"duplicate or incomplete release asset",
|
||||||
|
)
|
||||||
|
result[name] = (asset["id"], asset["size"], asset.get("digest"))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def verify_draft(root, version, tag):
|
||||||
|
release.version_tag(version, tag)
|
||||||
|
before = api(f"releases/tags/{tag}")
|
||||||
|
release.require(before.get("tag_name") == tag, "draft tag mismatch")
|
||||||
|
snapshot = asset_snapshot(before)
|
||||||
|
packages = release.expected_names(version)
|
||||||
|
expected = packages | {name + ".sig" for name in packages} | {"latest.json", "latest.json.sig"}
|
||||||
|
release.require(set(snapshot) == expected, "draft asset set is incomplete or unexpected")
|
||||||
|
# Reject remote names and declared sizes before gh writes any asset locally.
|
||||||
|
# This is only a pre-download bound; signatures and actual bytes are still
|
||||||
|
# checked below, followed by the remote identity/race check.
|
||||||
|
for name, (_, size, _) in snapshot.items():
|
||||||
|
limit = (
|
||||||
|
32 * 1024 if name == "latest.json"
|
||||||
|
else 8 * 1024 if name.endswith(".sig")
|
||||||
|
else release.MAX_SIZE
|
||||||
|
)
|
||||||
|
release.require(type(size) is int and 0 < size <= limit, "invalid remote draft asset size")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="shacraft-draft-check-") as temporary:
|
||||||
|
directory = Path(temporary)
|
||||||
|
gh("release", "download", tag, "--repo", release.REPOSITORY, "--dir", str(directory))
|
||||||
|
release.verify_release(root, directory, version, tag)
|
||||||
|
release.require(
|
||||||
|
set(snapshot) == {p.name for p in directory.iterdir()},
|
||||||
|
"draft assets changed during download",
|
||||||
|
)
|
||||||
|
for path in directory.iterdir():
|
||||||
|
release.require(snapshot[path.name][1] == path.stat().st_size, "draft size mismatch")
|
||||||
|
after = api(f"releases/tags/{tag}")
|
||||||
|
release.require(
|
||||||
|
after["id"] == before["id"] and asset_snapshot(after) == snapshot,
|
||||||
|
"draft changed during verification",
|
||||||
|
)
|
||||||
|
return after
|
||||||
|
|
||||||
|
|
||||||
|
def draft(root, directory, version, tag):
|
||||||
|
workflow_guard()
|
||||||
|
release.preflight(root, version, tag, check_git=True, signing=True)
|
||||||
|
release.verify_release(root, directory, version, tag)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="shacraft-release-notes-") as temporary:
|
||||||
|
notes = Path(temporary) / "notes.md"
|
||||||
|
release_notes = release.read_json(directory / "latest.json")["notes"]
|
||||||
|
instructions = (
|
||||||
|
f"https://github.com/{release.REPOSITORY}/blob/{tag}/docs/updater-release.md"
|
||||||
|
"#installed-package-migration-and-recovery"
|
||||||
|
)
|
||||||
|
notes.write_text(
|
||||||
|
release_notes + "\n\n" +
|
||||||
|
"[Установка, переход с 0.1.1 и восстановление](" + instructions + ").\n" +
|
||||||
|
"Используйте прежний тип пакета. Обновление .deb выполняется через менеджер пакетов.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
gh(
|
||||||
|
"release",
|
||||||
|
"create",
|
||||||
|
tag,
|
||||||
|
*[str(path) for path in sorted(directory.iterdir())],
|
||||||
|
"--repo",
|
||||||
|
release.REPOSITORY,
|
||||||
|
"--draft",
|
||||||
|
"--verify-tag",
|
||||||
|
"--title",
|
||||||
|
f"ShaCraft Launcher {version}",
|
||||||
|
"--notes-file",
|
||||||
|
str(notes),
|
||||||
|
)
|
||||||
|
verify_draft(root, version, tag)
|
||||||
|
print(
|
||||||
|
"Complete draft uploaded and re-verified. Publication requires the separate protected workflow."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def publish(root, version, tag, confirmation):
|
||||||
|
workflow_guard()
|
||||||
|
release.require(
|
||||||
|
confirmation == f"publish {tag}", "explicit publication confirmation does not match tag"
|
||||||
|
)
|
||||||
|
release.preflight(root, version, tag, check_git=True)
|
||||||
|
pinned = (root / "src-tauri/updater-public-key.txt").read_text().strip()
|
||||||
|
release.require(
|
||||||
|
release.public_key() == pinned, "publication key differs from committed updater key"
|
||||||
|
)
|
||||||
|
gate("launcher-release-publish") # Re-check protection immediately before publication.
|
||||||
|
current = api("releases/latest", missing=True)
|
||||||
|
if current is not None:
|
||||||
|
old_tag = current.get("tag_name", "")
|
||||||
|
release.version_tag(old_tag.removeprefix("v"), old_tag)
|
||||||
|
release.require(
|
||||||
|
tuple(map(int, version.split("."))) > tuple(map(int, old_tag[1:].split("."))),
|
||||||
|
"publication must advance the stable release version",
|
||||||
|
)
|
||||||
|
verify_draft(root, version, tag)
|
||||||
|
gh("release", "edit", tag, "--repo", release.REPOSITORY, "--draft=false", "--latest")
|
||||||
|
print("Verified release published by explicit protected operator workflow.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
item = sub.add_parser("gate")
|
||||||
|
item.add_argument("environment", choices=sorted(ENVIRONMENTS))
|
||||||
|
for name in ("draft", "publish"):
|
||||||
|
item = sub.add_parser(name)
|
||||||
|
item.add_argument("--version", required=True)
|
||||||
|
item.add_argument("--tag", required=True)
|
||||||
|
if name == "draft":
|
||||||
|
item.add_argument("--directory", required=True, type=Path)
|
||||||
|
else:
|
||||||
|
item.add_argument("--confirmation", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.command == "gate":
|
||||||
|
gate(args.environment)
|
||||||
|
elif args.command == "draft":
|
||||||
|
draft(args.root.resolve(), args.directory, args.version, args.tag)
|
||||||
|
else:
|
||||||
|
publish(args.root.resolve(), args.version, args.tag, args.confirmation)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (ValueError, OSError, subprocess.SubprocessError, KeyError, TypeError) as error:
|
||||||
|
print(f"Release stopped: {error}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
param([Parameter(Mandatory=$true)][string]$PackagePath)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||||
|
# MSIDBOPEN_READONLY = 0. Reading COM properties never installs the package.
|
||||||
|
$installer = New-Object -ComObject WindowsInstaller.Installer
|
||||||
|
$summary = $installer.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $installer, @($PackagePath, 0))
|
||||||
|
$template = $summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7))
|
||||||
|
$database = $installer.GetType().InvokeMember('OpenDatabase', 'InvokeMethod', $null, $installer, @($PackagePath, 0))
|
||||||
|
$view = $database.GetType().InvokeMember('OpenView', 'InvokeMethod', $null, $database, @('SELECT `Value` FROM `Property` WHERE `Property` = ''ProductVersion'''))
|
||||||
|
$view.GetType().InvokeMember('Execute', 'InvokeMethod', $null, $view, $null) | Out-Null
|
||||||
|
$record = $view.GetType().InvokeMember('Fetch', 'InvokeMethod', $null, $view, $null)
|
||||||
|
if ($null -eq $record) { throw 'Missing MSI ProductVersion' }
|
||||||
|
$version = $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @(1))
|
||||||
|
@{ template = [string]$template; version = [string]$version } | ConvertTo-Json -Compress
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
"""Real Tauri signature checks using disposable keys, never release credentials."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import plistlib
|
||||||
|
import shutil
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import release
|
||||||
|
from release_assets_test import DraftAssetGateTests # noqa: F401; unittest discovery
|
||||||
|
from release_gate_test import ReleaseGateTests # noqa: F401; unittest discovery
|
||||||
|
import release_formats
|
||||||
|
import release_github
|
||||||
|
|
||||||
|
ROOT = Path(os.environ.get("RELEASE_TEST_ROOT", Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_pe(machine=0x8664, magic=0x20B):
|
||||||
|
data = bytearray(256)
|
||||||
|
data[:2] = b"MZ"
|
||||||
|
struct.pack_into("<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()
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"bundle": {
|
|
||||||
"createUpdaterArtifacts": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
"""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
+101
-30
@@ -2093,6 +2093,15 @@ 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"
|
||||||
@@ -2258,6 +2267,16 @@ 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"
|
||||||
@@ -3361,13 +3380,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shacraft-launcher"
|
name = "shacraft-launcher"
|
||||||
version = "0.1.7"
|
version = "0.2.0"
|
||||||
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",
|
||||||
@@ -3377,14 +3394,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",
|
||||||
"tempfile",
|
"time",
|
||||||
"url",
|
"url",
|
||||||
"zeroize",
|
"zip",
|
||||||
"zip 2.4.2",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3615,6 +3632,20 @@ 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"
|
||||||
@@ -3662,7 +3693,7 @@ dependencies = [
|
|||||||
"tao-macros",
|
"tao-macros",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"url",
|
"url",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
@@ -3744,7 +3775,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"window-vibrancy",
|
"window-vibrancy",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3828,6 +3859,8 @@ 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",
|
||||||
@@ -3853,7 +3886,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
"zip 4.6.1",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3878,7 +3910,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3903,7 +3935,7 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"wry",
|
"wry",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4672,7 +4704,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"webview2-com-macros",
|
"webview2-com-macros",
|
||||||
"webview2-com-sys",
|
"webview2-com-sys",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-implement",
|
"windows-implement",
|
||||||
"windows-interface",
|
"windows-interface",
|
||||||
@@ -4696,7 +4728,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",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4752,11 +4784,23 @@ 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",
|
"windows-collections 0.2.0",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-future",
|
"windows-future 0.2.1",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-numerics",
|
"windows-numerics 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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]]
|
||||||
@@ -4768,6 +4812,15 @@ 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"
|
||||||
@@ -4802,7 +4855,18 @@ 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",
|
"windows-threading 0.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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]]
|
||||||
@@ -4849,6 +4913,16 @@ 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"
|
||||||
@@ -4987,6 +5061,15 @@ 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"
|
||||||
@@ -5218,7 +5301,7 @@ dependencies = [
|
|||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"webkit2gtk-sys",
|
"webkit2gtk-sys",
|
||||||
"webview2-com",
|
"webview2-com",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
"x11-dl",
|
"x11-dl",
|
||||||
@@ -5355,18 +5438,6 @@ 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"
|
||||||
|
|||||||
+8
-12
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "shacraft-launcher"
|
name = "shacraft-launcher"
|
||||||
version = "0.1.7"
|
version = "0.2.0"
|
||||||
description = "ShaCraft Minecraft launcher"
|
description = "ShaCraft Minecraft launcher"
|
||||||
authors = ["ShaCraft"]
|
authors = ["ShaCraft"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -20,21 +20,17 @@ 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 = { version = "2", features = ["pkcs8"] }
|
ed25519-dalek = "2"
|
||||||
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"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
tauri-plugin-updater = { version = "=2.11.0", default-features = false, features = ["rustls-tls"] }
|
||||||
tauri = { version = "2", features = ["test"] }
|
minisign-verify = "=0.2.5"
|
||||||
|
semver = "1"
|
||||||
|
time = { version = "0.3", features = ["parsing", "formatting"] }
|
||||||
|
reqwest-updater = { package = "reqwest", version = "0.13", default-features = false, features = ["rustls-no-provider"] }
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
//! 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,8 +30,10 @@ 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())?;
|
||||||
@@ -76,8 +78,10 @@ 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);
|
||||||
@@ -102,8 +106,10 @@ 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)
|
||||||
})
|
})
|
||||||
|
|||||||
+258
-182
@@ -1,7 +1,7 @@
|
|||||||
use super::data_dir;
|
use super::{data_dir, profiles::ProfileMetadata, shacraft::resolve_identity};
|
||||||
use crate::{
|
use crate::{
|
||||||
fabric, java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
|
installation_lock::InstallationLock, java, launch, manifest, mojang, neoforge,
|
||||||
settings, shacraft_account,
|
operations::LauncherOperations, profile, remote, runtime, session, settings, shacraft_account,
|
||||||
};
|
};
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -15,135 +15,158 @@ 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_executable: &Path,
|
java: &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 mojang_manifest =
|
let catalog = mojang::fetch_version_manifest(client).map_err(|e| e.to_string())?;
|
||||||
mojang::fetch_version_manifest(client).map_err(|error| error.to_string())?;
|
let entry = mojang::find_version(&catalog, &manifest.minecraft.version)
|
||||||
let vanilla_entry = mojang::find_version(&mojang_manifest, &manifest.minecraft.version)
|
.ok_or_else(|| format!("Mojang does not list {}", manifest.minecraft.version))?;
|
||||||
.ok_or_else(|| {
|
let vanilla = mojang::fetch_version_json(client, entry).map_err(|e| e.to_string())?;
|
||||||
format!(
|
// The installer may reuse an existing vanilla JAR without verifying it.
|
||||||
"Mojang does not list Minecraft version {}",
|
// Establish provider integrity BEFORE any NeoForge processor uses that input.
|
||||||
manifest.minecraft.version
|
mojang::ensure_client_jar(
|
||||||
)
|
client,
|
||||||
})?;
|
game_dir,
|
||||||
let vanilla =
|
&vanilla.id,
|
||||||
mojang::fetch_version_json(client, vanilla_entry).map_err(|error| error.to_string())?;
|
&vanilla
|
||||||
|
.downloads
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("Vanilla client download metadata is missing")?
|
||||||
|
.client,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
if manifest.minecraft.loader.kind == "neoforge" {
|
if manifest.minecraft.loader.kind == "neoforge" {
|
||||||
let installer_client = neoforge::http_client().map_err(|error| error.to_string())?;
|
let installer_client = neoforge::http_client().map_err(|e| e.to_string())?;
|
||||||
let neoforge_version = neoforge::ensure_client_installed(
|
let loader = neoforge::ensure_client_installed(
|
||||||
&installer_client,
|
&installer_client,
|
||||||
java_executable,
|
java,
|
||||||
game_dir,
|
game_dir,
|
||||||
cache_dir,
|
cache_dir,
|
||||||
&manifest.minecraft.loader.version,
|
&manifest.minecraft.loader.version,
|
||||||
|
&vanilla,
|
||||||
on_progress,
|
on_progress,
|
||||||
)
|
)
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string())
|
mojang::merge_versions(&vanilla, Some(&loader)).map_err(|e| e.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(|error| error.to_string())
|
mojang::merge_versions(&vanilla, None).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads and installs everything needed to run `profile_id`: the
|
/// Internal stages receive the same verified snapshot; none can refetch it.
|
||||||
/// exact Minecraft/loader version the ShaCraft-signed manifest specifies,
|
fn prepare(
|
||||||
/// a Java runtime if none is already usable, and game assets. Emits
|
directory: &Path,
|
||||||
/// `game-install-progress` throughout with real progress for every stage:
|
snapshot: &remote::VerifiedSnapshot,
|
||||||
/// download bytes for Java, installer-confirmed library/processor counts
|
progress: &impl Fn(&'static str) -> mojang::ProgressCallback,
|
||||||
/// for NeoForge, and download bytes for libraries/assets.
|
) -> Result<
|
||||||
|
(
|
||||||
|
mojang::MergedVersion,
|
||||||
|
java::JavaInstallation,
|
||||||
|
profile::ProfileInspection,
|
||||||
|
),
|
||||||
|
String,
|
||||||
|
> {
|
||||||
|
let manifest = &snapshot.manifest;
|
||||||
|
let root = directory.join("profiles").join(&manifest.id);
|
||||||
|
progress("mods")(0, 0);
|
||||||
|
profile::sync_snapshot(&root, snapshot).map_err(|e| e.to_string())?;
|
||||||
|
let inspection = profile::inspect(&root, manifest).map_err(|e| e.to_string())?;
|
||||||
|
if !inspection.up_to_date {
|
||||||
|
return Err(
|
||||||
|
"Синхронизация не завершена; запуск остановлен. Проверьте конфликты файлов.".into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let game_dir = directory.join("game");
|
||||||
|
let client = mojang::http_client().map_err(|e| e.to_string())?;
|
||||||
|
let runtime_client = runtime::http_client().map_err(|e| e.to_string())?;
|
||||||
|
let java = java::ensure_java(
|
||||||
|
&runtime_client,
|
||||||
|
&game_dir.join("runtime"),
|
||||||
|
manifest.minecraft.java_major,
|
||||||
|
&progress("java"),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let merged = resolve_merged_version(
|
||||||
|
&client,
|
||||||
|
manifest,
|
||||||
|
Path::new(&java.executable),
|
||||||
|
&game_dir,
|
||||||
|
&game_dir.join("cache"),
|
||||||
|
&progress("neoforge"),
|
||||||
|
)?;
|
||||||
|
let libraries_client = mojang::library_http_client().map_err(|e| e.to_string())?;
|
||||||
|
mojang::ensure_client_jar(
|
||||||
|
&client,
|
||||||
|
&game_dir,
|
||||||
|
&merged.client_jar_version_id,
|
||||||
|
&merged.client,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
mojang::ensure_libraries(
|
||||||
|
&libraries_client,
|
||||||
|
&game_dir,
|
||||||
|
&merged.libraries,
|
||||||
|
&progress("libraries"),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
mojang::ensure_assets(&client, &game_dir, &index, &progress("assets"))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok((merged, java, inspection))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct PreparationResult {
|
||||||
|
inspection: profile::ProfileInspection,
|
||||||
|
metadata: ProfileMetadata,
|
||||||
|
onboarding: Option<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<(), String> {
|
) -> Result<PreparationResult, String> {
|
||||||
let game_dir = data_dir(&app)?.join("game");
|
let directory = data_dir(&app)?;
|
||||||
let runtime_root = game_dir.join("runtime");
|
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
|
||||||
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 || {
|
||||||
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
|
let _lifecycle = lifecycle;
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
let client = mojang::http_client().map_err(|error| error.to_string())?;
|
let _lock = InstallationLock::acquire(&directory)?;
|
||||||
let runtime_client = runtime::http_client().map_err(|error| error.to_string())?;
|
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
|
||||||
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
let (_, _, inspection) = prepare(&directory, &snapshot, &|stage| progress(&app, stage))?;
|
||||||
|
Ok(PreparationResult {
|
||||||
let stage_progress = |stage: &'static str| -> mojang::ProgressCallback {
|
inspection,
|
||||||
let app = app.clone();
|
metadata: (&snapshot).into(),
|
||||||
Arc::new(move |current, total| {
|
onboarding: None,
|
||||||
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(|error| format!("Install task failed: {error}"))?
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
@@ -153,90 +176,143 @@ pub(crate) struct GameExited {
|
|||||||
exit_code: Option<i32>,
|
exit_code: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Launches `profile_id` with the verified ShaCraft account's linked nickname.
|
async fn play(
|
||||||
/// Local legacy nickname/account-mode preferences cannot override the link.
|
app: AppHandle,
|
||||||
/// Spawns the game detached; watches it on a
|
state: &LauncherOperations,
|
||||||
/// background thread only to emit `game-exited` when it eventually closes.
|
profile_id: String,
|
||||||
|
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<(), String> {
|
) -> Result<PreparationResult, String> {
|
||||||
let game_dir = data_dir(&app)?.join("game");
|
play(app, &state, profile_id, None).await
|
||||||
let data_dir = data_dir(&app)?;
|
}
|
||||||
let permit = state.installation.acquire("Installation")?;
|
#[tauri::command]
|
||||||
let game_permit = state.game.acquire("Игра")?;
|
pub(crate) async fn launch_onboarding(
|
||||||
let account_operation = state.shacraft_account.clone();
|
app: AppHandle,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
|
#[cfg(test)]
|
||||||
let _permit = permit;
|
mod tests {
|
||||||
let client = mojang::http_client().map_err(|error| error.to_string())?;
|
use super::*;
|
||||||
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())?;
|
|
||||||
|
|
||||||
// Everything here should already be installed by `ensure_game_installed`,
|
/// Real provider downloads and installer execution; never logs in or joins
|
||||||
// so these are expected to hit their fast paths; no progress to show.
|
/// a server. Artifacts stay in a fresh temporary directory for diagnosis.
|
||||||
let no_progress: mojang::ProgressCallback = Arc::new(|_, _| {});
|
#[test]
|
||||||
let java_install = java::ensure_java(
|
#[ignore = "downloads the real pack/game and executes the official installer; needs network and Java 21"]
|
||||||
&runtime_client,
|
fn live_cold_install_and_corruption_repair() {
|
||||||
&game_dir.join("runtime"),
|
let directory = std::env::temp_dir().canonicalize().unwrap().join(format!(
|
||||||
manifest.minecraft.java_major,
|
"shacraft-cold-install-{}",
|
||||||
&no_progress,
|
SystemTime::now()
|
||||||
)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.map_err(|error| error.to_string())?;
|
.unwrap()
|
||||||
let merged = resolve_merged_version(
|
.as_nanos()
|
||||||
&client,
|
));
|
||||||
&manifest,
|
assert!(!directory.exists());
|
||||||
Path::new(&java_install.executable),
|
eprintln!("Isolated installation: {}", directory.display());
|
||||||
&game_dir,
|
let _lock = InstallationLock::acquire(&directory).unwrap();
|
||||||
&game_dir.join("cache"),
|
let snapshot = remote::fetch_snapshot("aeronautics").unwrap();
|
||||||
&no_progress,
|
let callback = |stage| -> mojang::ProgressCallback {
|
||||||
)?;
|
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 mut child = launch::launch(&request).map_err(|error| error.to_string())?;
|
let (_, java, inspection) = prepare(&directory, &snapshot, &callback).unwrap();
|
||||||
|
assert!(inspection.up_to_date);
|
||||||
let watch_app = app.clone();
|
assert_eq!(java.major, snapshot.manifest.minecraft.java_major);
|
||||||
let watch_profile_id = profile_id.clone();
|
let game = directory.join("game");
|
||||||
std::thread::spawn(move || {
|
let version = &snapshot.manifest.minecraft.loader.version;
|
||||||
let game_permit = game_permit;
|
let json = neoforge::installed_version_json_path(&game, version);
|
||||||
let exit_code = child.wait().ok().and_then(|status| status.code());
|
let jar = game.join(format!(
|
||||||
drop(game_permit);
|
"libraries/net/neoforged/neoforge/{version}/neoforge-{version}-client.jar"
|
||||||
let _ = watch_app.emit(
|
));
|
||||||
"game-exited",
|
let original_json = std::fs::read(&json).unwrap();
|
||||||
GameExited {
|
std::fs::write(&json, b"nonempty corrupted version JSON").unwrap();
|
||||||
profile_id: watch_profile_id,
|
std::fs::write(&jar, b"nonempty corrupted patched JAR").unwrap();
|
||||||
exit_code,
|
let (_, _, repaired) = prepare(&directory, &snapshot, &callback).unwrap();
|
||||||
},
|
assert!(repaired.up_to_date);
|
||||||
);
|
assert_eq!(std::fs::read(&json).unwrap(), original_json);
|
||||||
});
|
assert!(std::fs::metadata(&jar).unwrap().len() > 1024);
|
||||||
|
// A third preparation verifies the receipt and all downloads again.
|
||||||
Ok(())
|
assert!(
|
||||||
})
|
prepare(&directory, &snapshot, &callback)
|
||||||
.await
|
.unwrap()
|
||||||
.map_err(|error| format!("Launch task failed: {error}"))?
|
.2
|
||||||
|
.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::settings;
|
use crate::{operations::LauncherOperations, settings};
|
||||||
use tauri::AppHandle;
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
#[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,11 +14,16 @@ 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)?;
|
||||||
tauri::async_runtime::spawn_blocking(move || settings::save(&data_dir, settings))
|
let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?;
|
||||||
.await
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
.map_err(|error| format!("Settings task failed: {error}"))?
|
let _lifecycle = lifecycle;
|
||||||
.map_err(|error| error.to_string())
|
settings::save(&data_dir, settings)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Settings task failed: {error}"))?
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,134 @@
|
|||||||
use super::data_dir;
|
use super::data_dir;
|
||||||
use crate::{operations::LauncherOperations, profile, remote};
|
use crate::{installation_lock::InstallationLock, operations::LauncherOperations, profile, remote};
|
||||||
|
use serde::Serialize;
|
||||||
use tauri::{AppHandle, State};
|
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(|error| error.to_string())
|
remote::fetch_server_status(&profile_id).map_err(|e| e.to_string())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|error| format!("Server-status task failed: {error}"))?
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads and validates the published ShaCraft manifest before inspecting a profile.
|
|
||||||
#[tauri::command]
|
#[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 data_dir = data_dir(&app)?;
|
let directory = data_dir(&app)?;
|
||||||
|
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
let _lifecycle = lifecycle;
|
||||||
profile::inspect(&data_dir.join("profiles").join(&manifest.id), &manifest)
|
// Inspection must not report a partially applied journal as ready.
|
||||||
.map_err(|error| error.to_string())
|
let _lock = InstallationLock::acquire(&directory)?;
|
||||||
|
let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?;
|
||||||
|
profile::inspect(&directory.join("profiles").join(&manifest.id), &manifest)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|error| format!("Profile inspection task failed: {error}"))?
|
.map_err(|e| e.to_string())?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads missing or changed ShaCraft-managed files from the fixed v2 endpoint.
|
|
||||||
#[tauri::command]
|
#[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 data_dir = data_dir(&app)?;
|
let directory = data_dir(&app)?;
|
||||||
|
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
|
||||||
let permit = state.installation.acquire("Installation")?;
|
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 manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
let _lock = InstallationLock::acquire(&directory)?;
|
||||||
profile::sync(&data_dir.join("profiles").join(&manifest.id), &manifest)
|
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
|
||||||
.map_err(|error| error.to_string())
|
profile::sync_snapshot(
|
||||||
|
&directory.join("profiles").join(&snapshot.manifest.id),
|
||||||
|
&snapshot,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|error| format!("Profile synchronization task failed: {error}"))?
|
.map_err(|e| e.to_string())?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) async fn legacy_mods(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, LauncherOperations>,
|
||||||
|
profile_id: String,
|
||||||
|
) -> Result<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,6 +1,9 @@
|
|||||||
//! 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::{operations::LauncherOperations, shacraft_account};
|
use crate::{
|
||||||
|
operations::{LauncherOperations, Operation},
|
||||||
|
session, shacraft_account,
|
||||||
|
};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
@@ -10,10 +13,12 @@ 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())
|
||||||
})
|
})
|
||||||
@@ -72,18 +77,6 @@ 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,
|
||||||
@@ -95,3 +88,35 @@ 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+132
-177
@@ -1,203 +1,158 @@
|
|||||||
|
//! No updater command accepts a URL, key, target, version, executable or arguments.
|
||||||
|
use super::data_dir;
|
||||||
use crate::{
|
use crate::{
|
||||||
operations::LauncherOperations,
|
operations::LauncherOperations,
|
||||||
updater::{self, LauncherUpdater, Stage, UpdateProgress, UpdateStatus},
|
update_guard,
|
||||||
|
updater::{self, UpdateStatus, UpdaterState},
|
||||||
};
|
};
|
||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
#[tauri::command]
|
fn pending(app: &AppHandle, state: &UpdaterState) -> Result<Option<UpdateStatus>, String> {
|
||||||
pub(crate) fn get_launcher_update_status(
|
let operations = app.state::<LauncherOperations>();
|
||||||
app: AppHandle,
|
if let Err(reason) = operations.ensure_writable() {
|
||||||
updater: State<'_, LauncherUpdater>,
|
return Ok(Some(state.recovery(app, reason)));
|
||||||
) -> Result<UpdateStatus, String> {
|
}
|
||||||
updater.status(&app)
|
let directory = data_dir(app)?;
|
||||||
|
match update_guard::pending_reason(&directory, env!("CARGO_PKG_VERSION")) {
|
||||||
|
Ok(Some(reason)) | Err(reason) => {
|
||||||
|
// Recovery discovered after startup is just as permanent for this
|
||||||
|
// process. External marker deletion cannot resume native writes.
|
||||||
|
operations.latch_recovery(reason.clone());
|
||||||
|
Ok(Some(state.recovery(app, reason)))
|
||||||
|
}
|
||||||
|
Ok(None) => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contain_worker_panic<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) async fn check_launcher_update(
|
pub(crate) fn updater_status(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
updater: State<'_, LauncherUpdater>,
|
state: State<'_, UpdaterState>,
|
||||||
) -> Result<UpdateStatus, String> {
|
) -> Result<UpdateStatus, String> {
|
||||||
let status = updater.status(&app)?;
|
if state.critical() {
|
||||||
if !status.supported || status.stage == Stage::Ready {
|
return Ok(state.status());
|
||||||
|
}
|
||||||
|
Ok(pending(&app, &state)?.unwrap_or_else(|| state.status()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) async fn updater_check(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, UpdaterState>,
|
||||||
|
) -> Result<UpdateStatus, String> {
|
||||||
|
let permit = state.acquire()?;
|
||||||
|
if let Some(status) = pending(&app, &state)? {
|
||||||
return Ok(status);
|
return Ok(status);
|
||||||
}
|
}
|
||||||
let permit = updater
|
let state = state.inner().clone();
|
||||||
.operation
|
let worker_app = app.clone();
|
||||||
.acquire("Проверка или установка обновления")
|
let worker_state = state.clone();
|
||||||
.map_err(|_| "Проверка или установка обновления уже выполняется.".to_string())?;
|
let worker = tauri::async_runtime::spawn_blocking(move || {
|
||||||
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;
|
||||||
let result = async {
|
// Publish failure from the worker even if its IPC caller disappeared.
|
||||||
let key = updater::public_key(&app)?;
|
contain_worker_panic(
|
||||||
updater::check_candidate(
|
|| updater::check(&worker_app, &worker_state),
|
||||||
updater::trusted_builder(&app)?,
|
"Проверка обновления прервалась. Повторите проверку.",
|
||||||
&key,
|
)
|
||||||
&app.package_info().version.to_string(),
|
.unwrap_or_else(|error| report_failure(&worker_app, &worker_state, error))
|
||||||
updater::installation_kind(&app),
|
});
|
||||||
)
|
Ok(worker.await.unwrap_or_else(|_| {
|
||||||
.await
|
report_failure(
|
||||||
}
|
&app,
|
||||||
.await;
|
&state,
|
||||||
{
|
"Проверка обновления прервалась. Повторите проверку.".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 install_launcher_update(
|
pub(crate) async fn updater_download_install(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
updater: State<'_, LauncherUpdater>,
|
state: State<'_, UpdaterState>,
|
||||||
operations: State<'_, LauncherOperations>,
|
) -> Result<UpdateStatus, String> {
|
||||||
) -> Result<(), String> {
|
let permit = state.acquire()?;
|
||||||
if let Some(reason) = updater::unsupported_reason(&app) {
|
if let Some(status) = pending(&app, &state)? {
|
||||||
return Err(reason);
|
return Ok(status);
|
||||||
}
|
}
|
||||||
let permit = updater
|
let directory = data_dir(&app)?;
|
||||||
.operation
|
let state = state.inner().clone();
|
||||||
.acquire("Проверка или установка обновления")
|
let worker_app = app.clone();
|
||||||
.map_err(|_| "Проверка или установка обновления уже выполняется.".to_string())?;
|
let worker_state = state.clone();
|
||||||
let candidate = {
|
let worker = tauri::async_runtime::spawn_blocking(move || {
|
||||||
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 _permit = permit;
|
||||||
let result = async {
|
contain_worker_panic(
|
||||||
let key = updater::public_key(&app)?;
|
|| {
|
||||||
let _ = app.emit(
|
let operations = worker_app.state::<LauncherOperations>();
|
||||||
"launcher-update-progress",
|
updater::download_install(&worker_app, &worker_state, &directory, &operations)
|
||||||
UpdateProgress {
|
},
|
||||||
stage: Stage::Downloading,
|
"Установка обновления прервалась; проверьте состояние перед повторной попыткой.",
|
||||||
downloaded_bytes: 0,
|
)
|
||||||
total_bytes: None,
|
.unwrap_or_else(|error| report_failure(&worker_app, &worker_state, error))
|
||||||
},
|
});
|
||||||
);
|
Ok(worker.await.unwrap_or_else(|_| {
|
||||||
let bytes =
|
report_failure(
|
||||||
updater::download_verified(&candidate, &key, |downloaded_bytes, total_bytes| {
|
&app,
|
||||||
let _ = app.emit(
|
&state,
|
||||||
"launcher-update-progress",
|
"Установка обновления прервалась; проверьте состояние перед повторной попыткой.".into(),
|
||||||
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]
|
#[tauri::command]
|
||||||
pub(crate) fn restart_launcher_after_update(
|
pub(crate) fn updater_restart(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
updater: State<'_, LauncherUpdater>,
|
state: State<'_, UpdaterState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let _permit = updater
|
let _permit = state.acquire()?;
|
||||||
.operation
|
if !state.ready() {
|
||||||
.acquire("Установка обновления")
|
return Err("Нет завершённого обновления для перезапуска".into());
|
||||||
.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,555 +0,0 @@
|
|||||||
//! 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
//! 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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
//! One writer for the entire shared game tree, across launcher instances.
|
||||||
|
//! The OS lock is retained by the child watcher. A durable process lease also
|
||||||
|
//! protects a detached Minecraft after the launcher exits (PID + start time,
|
||||||
|
//! never PID alone). An interrupted spawn with no recorded child fails closed.
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
fs::{self, File, OpenOptions},
|
||||||
|
io,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||||
|
struct ProcessIdentity {
|
||||||
|
pid: u32,
|
||||||
|
started: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(tag = "state")]
|
||||||
|
enum Lease {
|
||||||
|
Starting { launcher: ProcessIdentity },
|
||||||
|
Running { game: ProcessIdentity },
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_identity(pid: u32) -> Result<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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
//! Launcher-owned profile state lives next to, never inside, the payload tree.
|
||||||
|
//! Callers hold the installation lock. Local records are not remote manifests:
|
||||||
|
//! they describe completed launcher writes, and never adopt an existing file.
|
||||||
|
use crate::{download, manifest::is_portable_component, storage};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
fs,
|
||||||
|
io::{self, Read},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
sync::atomic::{AtomicU64, Ordering},
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_STATE_BYTES: u64 = 8 * 1024 * 1024;
|
||||||
|
static NEXT_TRANSACTION: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
pub(crate) struct Fingerprint {
|
||||||
|
pub size: u64,
|
||||||
|
pub sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
pub(crate) struct OwnedFile {
|
||||||
|
pub fingerprint: Fingerprint,
|
||||||
|
pub snapshot: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub(crate) struct Inventory {
|
||||||
|
pub version: u32,
|
||||||
|
pub files: BTreeMap<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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-6
@@ -60,18 +60,28 @@ 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) = detect() {
|
if let Some(installation) = find_matching(candidates(), required_major, check_candidate) {
|
||||||
if installation.major == required_major {
|
on_progress(1, 1);
|
||||||
on_progress(1, 1);
|
return Ok(installation);
|
||||||
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"
|
||||||
@@ -127,7 +137,48 @@ fn parse_major(version: &str) -> Option<u8> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{parse_major, parse_version};
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matching_path_runtime_is_not_hidden_by_wrong_java_home() {
|
||||||
|
let candidates = ["JAVA_HOME", "PATH"].map(PathBuf::from);
|
||||||
|
let found = find_matching(candidates, 21, |path| {
|
||||||
|
let major = if path == Path::new("JAVA_HOME") {
|
||||||
|
17
|
||||||
|
} else {
|
||||||
|
21
|
||||||
|
};
|
||||||
|
Some(JavaInstallation {
|
||||||
|
executable: path.display().to_string(),
|
||||||
|
major,
|
||||||
|
version: major.to_string(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(found.executable, "PATH");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matching_home_remains_preferred_and_unusable_candidates_are_skipped() {
|
||||||
|
let found = find_matching(["broken", "home", "path"].map(PathBuf::from), 21, |path| {
|
||||||
|
assert_ne!(path, Path::new("path"), "must stop at matching JAVA_HOME");
|
||||||
|
(path != Path::new("broken")).then(|| JavaInstallation {
|
||||||
|
executable: path.display().to_string(),
|
||||||
|
major: 21,
|
||||||
|
version: "21".into(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(found.executable, "home");
|
||||||
|
assert!(find_matching([PathBuf::from("newer")], 21, |path| Some(
|
||||||
|
JavaInstallation {
|
||||||
|
executable: path.display().to_string(),
|
||||||
|
major: 25,
|
||||||
|
version: "25".into()
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_modern_java_version() {
|
fn parses_modern_java_version() {
|
||||||
|
|||||||
+23
-90
@@ -1,10 +1,11 @@
|
|||||||
//! 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 use only the identity bound to the
|
//! profile. The `${auth_*}` placeholders are filled from a `PlayerIdentity`,
|
||||||
//! server-issued admission ticket. Its one-use proof stays out of arguments
|
//! which is either a real Microsoft-authenticated session (`msa::LoginResult`)
|
||||||
//! and files; only the Java child's environment receives it.
|
//! or an explicit offline account (`PlayerIdentity::Offline`). Offline mode is
|
||||||
|
//! 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},
|
||||||
@@ -43,9 +44,11 @@ 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 admission: &'a Admission,
|
pub identity: &'a PlayerIdentity,
|
||||||
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 {
|
||||||
@@ -182,10 +185,6 @@ 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)?;
|
||||||
@@ -196,8 +195,7 @@ fn build_command(request: &LaunchRequest) -> Result<Command, 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();
|
||||||
let identity = request.admission.identity();
|
vars.insert("auth_player_name", request.identity.name().to_string());
|
||||||
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
|
||||||
@@ -207,11 +205,14 @@ fn build_command(request: &LaunchRequest) -> Result<Command, 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", identity.uuid());
|
vars.insert("auth_uuid", request.identity.uuid());
|
||||||
vars.insert("auth_access_token", identity.access_token().to_string());
|
vars.insert(
|
||||||
|
"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", identity.xuid().to_string());
|
vars.insert("auth_xuid", request.identity.xuid().to_string());
|
||||||
vars.insert("user_type", identity.user_type().to_string());
|
vars.insert("user_type", request.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());
|
||||||
@@ -228,7 +229,6 @@ fn build_command(request: &LaunchRequest) -> Result<Command, 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,92 +245,25 @@ fn build_command(request: &LaunchRequest) -> Result<Command, LaunchError> {
|
|||||||
for argument in game_args {
|
for argument in game_args {
|
||||||
command.arg(substitute(&argument, &vars));
|
command.arg(substitute(&argument, &vars));
|
||||||
}
|
}
|
||||||
// This endpoint is native-owned; a manifest cannot redirect game admission.
|
|
||||||
if request.admission.server_id() == "minigames" {
|
|
||||||
command.args(["--quickPlayMultiplayer", "shacraft.ru:25568"]);
|
|
||||||
}
|
|
||||||
command.current_dir(request.profile_dir);
|
command.current_dir(request.profile_dir);
|
||||||
|
// Do not inherit a stale grant from the launcher process environment.
|
||||||
|
command.env_remove("SHACRAFT_ONBOARDING_TOKEN");
|
||||||
|
if let Some(token) = request.onboarding_token {
|
||||||
|
command.env("SHACRAFT_ONBOARDING_TOKEN", token);
|
||||||
|
}
|
||||||
|
command.stdin(Stdio::null());
|
||||||
|
|
||||||
let log_file = fs::File::create(request.log_path)?;
|
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)
|
Ok(command.spawn()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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();
|
||||||
|
|||||||
+39
-27
@@ -1,8 +1,9 @@
|
|||||||
mod admission;
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod deb_updater;
|
|
||||||
mod download;
|
mod download;
|
||||||
mod fabric;
|
mod update_guard;
|
||||||
|
mod updater;
|
||||||
|
use tauri::Manager;
|
||||||
|
mod installation_lock;
|
||||||
|
mod inventory;
|
||||||
mod java;
|
mod java;
|
||||||
mod launch;
|
mod launch;
|
||||||
mod manifest;
|
mod manifest;
|
||||||
@@ -17,37 +18,49 @@ 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::LauncherUpdater::default())
|
.manage(updater::UpdaterState::default())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(
|
||||||
|
tauri_plugin_updater::Builder::new()
|
||||||
|
.pubkey(updater::configured_key().unwrap_or(""))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.setup(|app| {
|
||||||
|
let directory = app.path().app_data_dir()?;
|
||||||
|
let instance =
|
||||||
|
update_guard::InstanceGuard::acquire(&directory, env!("CARGO_PKG_VERSION"))
|
||||||
|
.map_err(std::io::Error::other)?;
|
||||||
|
if let Some(reason) = instance.recovery_reason() {
|
||||||
|
app.state::<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 {
|
||||||
// Do not interrupt the short package replacement step. A
|
if window
|
||||||
// download can safely be abandoned before any file changes.
|
.app_handle()
|
||||||
if let Some(updater) = window.try_state::<updater::LauncherUpdater>() {
|
.state::<operations::LauncherOperations>()
|
||||||
if updater
|
.lifecycle
|
||||||
.state
|
.is_updating()
|
||||||
.lock()
|
{
|
||||||
.is_ok_and(|state| state.stage == updater::Stage::Installing)
|
api.prevent_close();
|
||||||
{
|
|
||||||
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,
|
||||||
@@ -55,23 +68,22 @@ 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::updater::get_launcher_update_status,
|
commands::game::launch_onboarding
|
||||||
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)
|
||||||
|| !matches!(manifest.minecraft.loader.kind.as_str(), "neoforge" | "fabric" | "vanilla")
|
|| manifest.minecraft.loader.kind.trim().is_empty()
|
||||||
|| !is_version(&manifest.minecraft.loader.version)
|
|| !is_version(&manifest.minecraft.loader.version)
|
||||||
{
|
{
|
||||||
return Err(ManifestError::Invalid(
|
return Err(ManifestError::Invalid(
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ 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),
|
||||||
)
|
)
|
||||||
@@ -68,7 +67,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) || crate::trusted_http::allows(url, &[crate::fabric::MAVEN_HOST])
|
is_allowed_host(url) || crate::neoforge::is_allowed_host(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
+58
-93
@@ -25,6 +25,9 @@
|
|||||||
//! 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;
|
||||||
@@ -56,6 +59,7 @@ 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,
|
||||||
@@ -76,6 +80,9 @@ 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,
|
||||||
@@ -166,25 +173,8 @@ 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
|
||||||
/// exactly which libraries it will download and which processors it will
|
/// which libraries it may download and which processors it may
|
||||||
/// run to patch the client — the same manifest the installer itself reads.
|
/// 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.
|
||||||
@@ -316,66 +306,51 @@ fn run_installer_with_progress(
|
|||||||
Ok((status.code(), tail))
|
Ok((status.code(), tail))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensures NeoForge `loader_version` is installed into the shared
|
/// Verifies a generated installation against its provenance receipt. Legacy
|
||||||
/// `game_dir` (vanilla libraries/version must already be there so the
|
/// installations and corrupt outputs are rebuilt by the verified official
|
||||||
/// installer can reuse them). No-op if already installed. Runs the
|
/// installer in an empty staging directory. The caller must ensure vanilla's
|
||||||
/// installer headlessly with `java_executable`; its own network calls go
|
/// client JAR first; the staged copy is checked against `vanilla` again before
|
||||||
/// straight to `maven.neoforged.net`/Mojang, outside our control, which is
|
/// any processor runs. No existing generated artifacts are adopted as trusted.
|
||||||
/// 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 version_json_path = installed_version_json_path(game_dir, loader_version);
|
let installer_path = ensure_installer(client, cache_dir, loader_version)?;
|
||||||
if !installation_complete(game_dir, loader_version) {
|
let mut rebuilt = false;
|
||||||
ensure_launcher_profiles_stub(game_dir)?;
|
let version = repair::ensure(
|
||||||
let installer_path = ensure_installer(client, cache_dir, loader_version)?;
|
&installer_path,
|
||||||
|
game_dir,
|
||||||
// A leftover version JSON makes some installer versions treat the
|
cache_dir,
|
||||||
// profile as already installed even when the patched client was
|
loader_version,
|
||||||
// deleted or quarantined. Remove only that generated marker so the
|
vanilla,
|
||||||
// official installer is forced to rebuild the incomplete profile.
|
|stage| {
|
||||||
match fs::remove_file(&version_json_path) {
|
rebuilt = true;
|
||||||
Ok(()) => {}
|
let (total_libraries, total_processors) =
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
read_install_profile_counts(&installer_path).unwrap_or((0, 0));
|
||||||
Err(error) => return Err(NeoForgeError::Io(error)),
|
let total = (total_libraries + total_processors).max(1);
|
||||||
}
|
on_progress(0, total);
|
||||||
|
run_installer_with_progress(
|
||||||
let (total_libraries, total_processors) =
|
java_executable,
|
||||||
read_install_profile_counts(&installer_path).unwrap_or((0, 0));
|
&installer_path,
|
||||||
let total = (total_libraries + total_processors).max(1);
|
stage,
|
||||||
on_progress(0, total);
|
cache_dir,
|
||||||
|
total_libraries,
|
||||||
let (exit_code, tail) = run_installer_with_progress(
|
total,
|
||||||
java_executable,
|
on_progress,
|
||||||
&installer_path,
|
)?;
|
||||||
game_dir,
|
on_progress(total, total);
|
||||||
cache_dir,
|
Ok(())
|
||||||
total_libraries,
|
},
|
||||||
total,
|
)?;
|
||||||
on_progress,
|
if !rebuilt {
|
||||||
)?;
|
|
||||||
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)]
|
||||||
@@ -412,25 +387,6 @@ 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);
|
||||||
@@ -492,8 +448,7 @@ 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 an empty game dir (it fetches and patches vanilla 1.21.1
|
/// into a staging game dir with a verified vanilla 1.21.1 input and checks the
|
||||||
/// 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
|
||||||
@@ -504,8 +459,10 @@ mod tests {
|
|||||||
use crate::{java, mojang};
|
use crate::{java, mojang};
|
||||||
|
|
||||||
let client = Client::builder().build().unwrap();
|
let client = Client::builder().build().unwrap();
|
||||||
let root =
|
let root = std::env::temp_dir()
|
||||||
std::env::temp_dir().join(format!("shacraft-neoforge-pipeline-{}", std::process::id()));
|
.canonicalize()
|
||||||
|
.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();
|
||||||
@@ -518,8 +475,14 @@ 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();
|
||||||
|
|
||||||
// The installer fetches and patches vanilla itself; we don't
|
// Verify vanilla before the installer is allowed to use it.
|
||||||
// pre-download it. It only needs a Java runtime and an empty dir.
|
mojang::ensure_client_jar(
|
||||||
|
&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);
|
||||||
@@ -531,6 +494,7 @@ mod tests {
|
|||||||
&game_dir,
|
&game_dir,
|
||||||
&cache_dir,
|
&cache_dir,
|
||||||
"21.1.248",
|
"21.1.248",
|
||||||
|
&vanilla,
|
||||||
&progress,
|
&progress,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -577,6 +541,7 @@ mod tests {
|
|||||||
&game_dir,
|
&game_dir,
|
||||||
&cache_dir,
|
&cache_dir,
|
||||||
"21.1.248",
|
"21.1.248",
|
||||||
|
&vanilla,
|
||||||
&no_progress,
|
&no_progress,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,765 @@
|
|||||||
|
//! 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
-69
@@ -3,46 +3,17 @@
|
|||||||
//! 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, Ordering},
|
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||||
Arc,
|
Arc, Mutex,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(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)]
|
||||||
@@ -67,7 +38,7 @@ impl Drop for Permit {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{LauncherOperations, Operation};
|
use super::Operation;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_overlap_and_releases_on_worker_error() {
|
fn rejects_overlap_and_releases_on_worker_error() {
|
||||||
@@ -80,43 +51,63 @@ mod tests {
|
|||||||
assert!(worker().is_err());
|
assert!(worker().is_err());
|
||||||
assert!(operation.acquire("Installation").is_ok());
|
assert!(operation.acquire("Installation").is_ok());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
// Owned, Send permits are held by the worker, not the awaiting IPC future.
|
||||||
fn running_game_blocks_update_and_partial_locks_are_released() {
|
// Readers represent all native operations which can persist or launch; the
|
||||||
let operations = LauncherOperations::default();
|
// sole writer represents launcher replacement, including its download stage.
|
||||||
let game = operations.game.acquire("game").unwrap();
|
#[derive(Default)]
|
||||||
assert!(operations.acquire_update().is_err());
|
pub(crate) struct Lifecycle(Arc<AtomicUsize>);
|
||||||
assert!(operations.installation.acquire("install").is_ok());
|
const EXCLUSIVE: usize = usize::MAX;
|
||||||
assert!(operations.account.acquire("account").is_ok());
|
impl Lifecycle {
|
||||||
assert!(operations
|
pub fn shared(&self) -> Result<SharedPermit, String> {
|
||||||
.shacraft_account
|
self.0
|
||||||
.acquire("ShaCraft account")
|
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| {
|
||||||
.is_ok());
|
if n < EXCLUSIVE - 1 {
|
||||||
drop(game);
|
Some(n + 1)
|
||||||
assert!(operations.acquire_update().is_ok());
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map_err(|_| "Лаунчер обновляется. Дождитесь завершения и перезапуска.".to_string())?;
|
||||||
|
Ok(SharedPermit(self.0.clone()))
|
||||||
}
|
}
|
||||||
|
pub fn exclusive(&self) -> Result<ExclusivePermit, String> {
|
||||||
#[test]
|
self.0
|
||||||
fn update_excludes_game_and_accounts_until_permit_drop() {
|
.compare_exchange(0, EXCLUSIVE, Ordering::AcqRel, Ordering::Acquire)
|
||||||
let operations = LauncherOperations::default();
|
.map_err(|_| {
|
||||||
let permit = operations.acquire_update().unwrap();
|
"Завершите операцию с игрой, настройками или аккаунтом и повторите обновление."
|
||||||
assert!(operations.installation.acquire("install").is_err());
|
.to_string()
|
||||||
assert!(operations.account.acquire("account").is_err());
|
})?;
|
||||||
assert!(operations
|
Ok(ExclusivePermit(self.0.clone()))
|
||||||
.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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
impl LauncherOperations {
|
||||||
fn account_operation_blocks_update_without_stranding_installation() {
|
/// Startup recovery is latched for this process. Removing a marker in a
|
||||||
let operations = LauncherOperations::default();
|
/// running application is never authority to resume writes or launch.
|
||||||
let _account = operations.shacraft_account.acquire("account").unwrap();
|
pub fn latch_recovery(&self, reason: String) {
|
||||||
assert!(operations.acquire_update().is_err());
|
*self.recovery.lock().unwrap() = Some(reason);
|
||||||
assert!(operations.installation.acquire("install").is_ok());
|
}
|
||||||
|
pub fn ensure_writable(&self) -> Result<(), String> {
|
||||||
|
match self.recovery.lock().unwrap().as_ref() {
|
||||||
|
Some(reason) => Err(reason.clone()),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+731
-111
@@ -1,8 +1,12 @@
|
|||||||
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::Serialize;
|
use serde::{Deserialize, 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,
|
||||||
@@ -15,6 +19,10 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +33,7 @@ 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)]
|
||||||
@@ -33,54 +42,121 @@ 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 } => {
|
Self::Download { path, source } => write!(formatter, "Download failed for {path}: {source}"),
|
||||||
write!(formatter, "Download failed for {path}: {source}")
|
Self::UnsafePath(path) => write!(formatter, "Unsafe or protected profile path: {}", path.display()),
|
||||||
}
|
Self::Conflict(paths) => write!(formatter, "Файлы изменены или не принадлежат лаунчеру; сохранены без изменений: {}. Проверьте список пользовательских модов.", paths.join(", ")),
|
||||||
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 path = managed_target(root, &expected.path)?;
|
let actual = current(root, &expected.path)?;
|
||||||
if !path.try_exists().map_err(ProfileError::Io)? {
|
if actual.is_none() {
|
||||||
missing_files += 1;
|
missing_files += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if matches!(expected.policy, FilePolicy::Seed) && path.is_file() {
|
if expected.policy == FilePolicy::Seed {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let checksum = Checksum::Sha256(expected.sha256.clone());
|
if actual.as_ref() != Some(&expected_fingerprint(expected)) {
|
||||||
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,
|
||||||
up_to_date: missing_files == 0 && mismatched_files == 0,
|
stale_files,
|
||||||
|
pending_update,
|
||||||
|
legacy_files,
|
||||||
|
up_to_date: missing_files == 0
|
||||||
|
&& mismatched_files == 0
|
||||||
|
&& stale_files == 0
|
||||||
|
&& conflicts.is_empty()
|
||||||
|
&& !pending_update,
|
||||||
|
conflicts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError> {
|
pub fn sync_snapshot(
|
||||||
|
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))
|
||||||
@@ -95,64 +171,309 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError
|
|||||||
}))
|
}))
|
||||||
.build()
|
.build()
|
||||||
.map_err(ProfileError::Network)?;
|
.map_err(ProfileError::Network)?;
|
||||||
|
sync_with_snapshot(root, &snapshot.manifest, &snapshot.digest, |file, stage| {
|
||||||
let mut downloaded_files = 0;
|
download_managed_file(&client, file, stage)
|
||||||
let mut reused_files = 0;
|
|
||||||
let mut downloaded_bytes = 0;
|
|
||||||
|
|
||||||
for expected in &manifest.files {
|
|
||||||
let target = managed_target(root, &expected.path)?;
|
|
||||||
if matches!(expected.policy, FilePolicy::Seed) && target.is_file() {
|
|
||||||
reused_files += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(SyncResult {
|
|
||||||
root: root.display().to_string(),
|
|
||||||
downloaded_files,
|
|
||||||
reused_files,
|
|
||||||
downloaded_bytes,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reject pre-existing links in the managed subtree before inspecting or
|
#[cfg(test)]
|
||||||
/// replacing files. A signed relative path must not follow a local link into
|
fn sync_with(
|
||||||
/// an unrelated directory. This is not a sandbox against a hostile local user
|
root: &Path,
|
||||||
/// changing directories concurrently under the launcher's OS identity.
|
manifest: &Manifest,
|
||||||
fn managed_target(root: &Path, relative: &str) -> Result<PathBuf, ProfileError> {
|
download: impl FnMut(&ManagedFile, &Path) -> Result<u64, ProfileError>,
|
||||||
let mut path = root.to_path_buf();
|
) -> Result<SyncResult, ProfileError> {
|
||||||
if let Some(parent) = root.parent() {
|
sync_with_snapshot(root, manifest, &manifest_snapshot(manifest), download)
|
||||||
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> {
|
fn sync_with_snapshot(
|
||||||
match fs::symlink_metadata(path) {
|
root: &Path,
|
||||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
manifest: &Manifest,
|
||||||
Err(ProfileError::UnsafePath(path.to_path_buf()))
|
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 removed_files = 0;
|
||||||
|
let paths: HashSet<_> = manifest
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.map(|file| file.path.as_str())
|
||||||
|
.collect();
|
||||||
|
for expected in &manifest.files {
|
||||||
|
let actual = current(root, &expected.path)?;
|
||||||
|
let fingerprint = expected_fingerprint(expected);
|
||||||
|
if expected.policy == FilePolicy::Seed && actual.is_some() {
|
||||||
|
next.files.remove(&expected.path); // relinquish managed -> seed, preserving edits
|
||||||
|
reused_files += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if actual.as_ref() == Some(&fingerprint) {
|
||||||
|
// Matching legacy bytes prove content, never launcher ownership.
|
||||||
|
if previous.files.get(&expected.path).map(|f| &f.fingerprint) != actual.as_ref() {
|
||||||
|
next.files.remove(&expected.path);
|
||||||
|
}
|
||||||
|
reused_files += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if actual.is_some()
|
||||||
|
&& previous.files.get(&expected.path).map(|f| &f.fingerprint) != actual.as_ref()
|
||||||
|
{
|
||||||
|
conflicts.push(expected.path.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
downloads.push((changes.len(), expected));
|
||||||
|
changes.push(Change {
|
||||||
|
path: expected.path.clone(),
|
||||||
|
before: actual,
|
||||||
|
after: Some(fingerprint.clone()),
|
||||||
|
});
|
||||||
|
if expected.policy == FilePolicy::Managed {
|
||||||
|
next.files.insert(
|
||||||
|
expected.path.clone(),
|
||||||
|
OwnedFile {
|
||||||
|
fingerprint,
|
||||||
|
snapshot: snapshot.to_owned(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
next.files.remove(&expected.path);
|
||||||
}
|
}
|
||||||
Ok(_) => Ok(()),
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
|
||||||
Err(error) => Err(ProfileError::Io(error)),
|
|
||||||
}
|
}
|
||||||
|
for (path, previous_file) in &previous.files {
|
||||||
|
if paths.contains(path.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match current(root, path)? {
|
||||||
|
None => {
|
||||||
|
next.files.remove(path);
|
||||||
|
}
|
||||||
|
Some(actual) if actual == previous_file.fingerprint => {
|
||||||
|
changes.push(Change {
|
||||||
|
path: path.clone(),
|
||||||
|
before: Some(actual),
|
||||||
|
after: None,
|
||||||
|
});
|
||||||
|
next.files.remove(path);
|
||||||
|
removed_files += 1;
|
||||||
|
}
|
||||||
|
Some(_) => conflicts.push(path.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !conflicts.is_empty() {
|
||||||
|
return Err(ProfileError::Conflict(conflicts));
|
||||||
|
}
|
||||||
|
if changes.is_empty() && next == previous {
|
||||||
|
return Ok(SyncResult {
|
||||||
|
root: root.display().to_string(),
|
||||||
|
downloaded_files: 0,
|
||||||
|
reused_files,
|
||||||
|
downloaded_bytes: 0,
|
||||||
|
removed_files: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let transaction = store.transaction().map_err(ProfileError::Io)?;
|
||||||
|
let mut downloaded_bytes = 0;
|
||||||
|
for (index, file) in &downloads {
|
||||||
|
let stage = store
|
||||||
|
.stage(&transaction, *index)
|
||||||
|
.map_err(ProfileError::Io)?;
|
||||||
|
downloaded_bytes += download(file, &stage)?;
|
||||||
|
if inventory::fingerprint(&stage)
|
||||||
|
.map_err(ProfileError::Io)?
|
||||||
|
.as_ref()
|
||||||
|
!= Some(&expected_fingerprint(file))
|
||||||
|
{
|
||||||
|
return Err(ProfileError::Io(inventory::invalid(
|
||||||
|
"Staged profile file failed verification",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Persist the whole future ownership set before the first payload change.
|
||||||
|
store
|
||||||
|
.prepare(transaction, changes, next)
|
||||||
|
.map_err(ProfileError::Io)?;
|
||||||
|
store.recover().map_err(ProfileError::Io)?;
|
||||||
|
Ok(SyncResult {
|
||||||
|
root: root.display().to_string(),
|
||||||
|
downloaded_files: downloads.len(),
|
||||||
|
reused_files,
|
||||||
|
downloaded_bytes,
|
||||||
|
removed_files,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LegacyMod {
|
||||||
|
pub path: String,
|
||||||
|
pub size: u64,
|
||||||
|
pub sha256: String,
|
||||||
|
pub reason: &'static str,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||||
|
pub struct LegacySelection {
|
||||||
|
pub path: String,
|
||||||
|
pub sha256: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LegacyBackup {
|
||||||
|
pub backup_root: String,
|
||||||
|
pub files: Vec<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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn managed_target(root: &Path, relative: &str) -> Result<PathBuf, ProfileError> {
|
||||||
|
if inventory::protected(relative) {
|
||||||
|
return Err(ProfileError::UnsafePath(root.join(relative)));
|
||||||
|
}
|
||||||
|
inventory::checked_path(root, relative)
|
||||||
|
.map_err(|_| ProfileError::UnsafePath(root.join(relative)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn download_managed_file(
|
fn download_managed_file(
|
||||||
@@ -160,18 +481,17 @@ 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,
|
&Checksum::Sha256(expected.sha256.clone()),
|
||||||
|_, _| {},
|
|_, _| {},
|
||||||
)
|
)
|
||||||
.map_err(|error| ProfileError::Download {
|
.map_err(|source| ProfileError::Download {
|
||||||
path: expected.path.clone(),
|
path: expected.path.clone(),
|
||||||
source: error,
|
source,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,45 +499,17 @@ 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},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
fn trusted_temporary_root() -> std::path::PathBuf {
|
||||||
#[ignore = "downloads the public signed admission mod into a temporary directory"]
|
// Resolve only the OS-provided fixture anchor. The profile root, its
|
||||||
fn live_admission_mod_is_restored_when_missing_or_corrupt() {
|
// parent and payload descendants retain their production link checks.
|
||||||
let mut manifest = crate::remote::fetch_manifest("aeronautics").unwrap();
|
std::env::temp_dir().canonicalize().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 {
|
||||||
@@ -245,7 +537,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reports_missing_and_matching_files() {
|
fn reports_missing_and_matching_files() {
|
||||||
let root = std::env::temp_dir().join(format!(
|
let root = trusted_temporary_root().join(format!(
|
||||||
"shacraft-launcher-test-{}-{}",
|
"shacraft-launcher-test-{}-{}",
|
||||||
process::id(),
|
process::id(),
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
@@ -273,7 +565,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn edited_seed_files_remain_up_to_date() {
|
fn edited_seed_files_remain_up_to_date() {
|
||||||
let root = std::env::temp_dir().join(format!(
|
let root = trusted_temporary_root().join(format!(
|
||||||
"shacraft-seed-test-{}-{}",
|
"shacraft-seed-test-{}-{}",
|
||||||
process::id(),
|
process::id(),
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
@@ -291,7 +583,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn preserves_changed_seed_files_as_current() {
|
fn preserves_changed_seed_files_as_current() {
|
||||||
let root = std::env::temp_dir().join(format!(
|
let root = trusted_temporary_root().join(format!(
|
||||||
"shacraft-launcher-seed-test-{}-{}",
|
"shacraft-launcher-seed-test-{}-{}",
|
||||||
process::id(),
|
process::id(),
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
@@ -314,7 +606,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 = std::env::temp_dir().join(format!(
|
let root = trusted_temporary_root().join(format!(
|
||||||
"shacraft-link-test-{}-{}",
|
"shacraft-link-test-{}-{}",
|
||||||
process::id(),
|
process::id(),
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
@@ -332,3 +624,331 @@ 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+22
-6
@@ -4,6 +4,7 @@ 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},
|
||||||
@@ -13,8 +14,6 @@ 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";
|
||||||
@@ -70,10 +69,18 @@ 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()
|
||||||
@@ -93,7 +100,7 @@ pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, 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_envelope(&source, profile_id, &public_key)
|
verify_snapshot(&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> {
|
||||||
@@ -128,7 +135,6 @@ 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()
|
||||||
@@ -158,11 +164,20 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -182,12 +197,13 @@ fn verify_envelope(
|
|||||||
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(manifest)
|
Ok(VerifiedSnapshot { manifest, digest })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+144
-123
@@ -6,12 +6,7 @@
|
|||||||
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::{
|
use std::{fmt, fs, io, path::Path, time::Duration};
|
||||||
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";
|
||||||
@@ -50,9 +45,21 @@ 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)]
|
||||||
@@ -67,6 +74,7 @@ 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 {
|
||||||
@@ -76,6 +84,9 @@ 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,7 +215,92 @@ pub fn start_link(
|
|||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(api_error(response));
|
return Err(api_error(response));
|
||||||
}
|
}
|
||||||
response.json::<LinkStart>().map_err(AccountError::Network)
|
let value = response
|
||||||
|
.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> {
|
||||||
@@ -222,101 +318,56 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ADMISSION_ENDPOINT: &str = "/api/launcher/v2/admission/tickets";
|
pub fn aeronautics_nickname(data_dir: &Path) -> Result<String, AccountError> {
|
||||||
|
get_account(data_dir)?
|
||||||
/// Error responses at this boundary never echo arbitrary response bodies: a
|
.links
|
||||||
/// misconfigured proxy/service must not copy credentials into UI diagnostics.
|
.into_iter()
|
||||||
fn admission_error(status: reqwest::StatusCode) -> AccountError {
|
.find(|link| link.server_id == "aoc")
|
||||||
use reqwest::StatusCode;
|
.map(|link| link.mc_username)
|
||||||
match status {
|
.ok_or(AccountError::NoLinkedNickname)
|
||||||
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::{
|
use super::{load_session, save_session, session_path};
|
||||||
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-{}-{}",
|
||||||
@@ -354,34 +405,4 @@ 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("зарезервирован"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
//! Launcher replacement is a distinct trust and lifecycle boundary from game
|
||||||
|
//! installation. A process-local gate drains all native writes; the game lock
|
||||||
|
//! also checks the durable detached-game lease. A handoff record survives the
|
||||||
|
//! Windows updater's immediate process exit and is never cleared by a timeout.
|
||||||
|
use crate::{
|
||||||
|
installation_lock::InstallationLock,
|
||||||
|
operations::{ExclusivePermit, LauncherOperations, Permit},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
fs::{self, File, OpenOptions},
|
||||||
|
io,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
// A renamed durable marker must survive a power loss before installer handoff.
|
||||||
|
// Unix directory fsync persists the name itself, in addition to write_atomic's
|
||||||
|
// fsync of the file contents. Windows uses its native file replacement semantics.
|
||||||
|
fn sync_directory(path: &Path) -> Result<(), String> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
File::open(path)
|
||||||
|
.and_then(|file| file.sync_all())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let _ = path;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECOVERY: &str = "Предыдущая установка обновления не завершена или её запись повреждена. Автоматическое продолжение заблокировано. Закройте игру, установщик и лаунчер, затем восстановите приложение официальным пакетом того же типа. Настройки и игровые файлы удалять не нужно.";
|
||||||
|
|
||||||
|
fn ordinary(path: &Path) -> Result<(), String> {
|
||||||
|
match fs::symlink_metadata(path) {
|
||||||
|
Ok(meta) if meta.file_type().is_symlink() => {
|
||||||
|
Err("Служебный путь обновления является ссылкой".into())
|
||||||
|
}
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(error) => Err(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn directory(data: &Path) -> Result<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+588
-777
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
|||||||
|
//! Do not let the plugin's byte sniffing select a different Windows installer
|
||||||
|
//! than the installed package. Metadata, signature and these checks all agree.
|
||||||
|
use super::PackageMode;
|
||||||
|
|
||||||
|
pub(super) fn verify(mode: &PackageMode, bytes: &[u8]) -> Result<(), String> {
|
||||||
|
let valid = match mode {
|
||||||
|
PackageMode::Automatic {
|
||||||
|
platform: "windows-x86_64",
|
||||||
|
msi: true,
|
||||||
|
} => bytes.starts_with(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"),
|
||||||
|
PackageMode::Automatic {
|
||||||
|
platform: "windows-x86_64",
|
||||||
|
msi: false,
|
||||||
|
} => {
|
||||||
|
let pe = bytes
|
||||||
|
.get(0x3c..0x40)
|
||||||
|
.map(|raw| u32::from_le_bytes(raw.try_into().unwrap()) as usize);
|
||||||
|
bytes.starts_with(b"MZ")
|
||||||
|
&& pe.and_then(|at| at.checked_add(4).and_then(|end| bytes.get(at..end)))
|
||||||
|
== Some(b"PE\0\0".as_slice())
|
||||||
|
}
|
||||||
|
PackageMode::Automatic {
|
||||||
|
platform: "linux-x86_64",
|
||||||
|
msi: false,
|
||||||
|
} => {
|
||||||
|
bytes.starts_with(b"\x7fELF\x02\x01") // ELF64, little-endian
|
||||||
|
&& bytes.get(8..11) == Some(b"AI\x02".as_slice()) // AppImage Type2 magic
|
||||||
|
&& bytes.get(18..20) == Some(b"\x3e\x00".as_slice())
|
||||||
|
} // EM_X86_64
|
||||||
|
PackageMode::Automatic {
|
||||||
|
platform: "darwin-aarch64" | "darwin-x86_64",
|
||||||
|
msi: false,
|
||||||
|
} => bytes.starts_with(b"\x1f\x8b\x08"), // gzip archive; actual .app architecture is signed in metadata
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if valid {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("Формат подписанного пакета не соответствует установленному лаунчеру. Автоматическая смена типа установки запрещена.".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn installed_label() -> &'static str {
|
||||||
|
use tauri::utils::{config::BundleType, platform::bundle_type};
|
||||||
|
if cfg!(debug_assertions) {
|
||||||
|
return "development";
|
||||||
|
}
|
||||||
|
match bundle_type() {
|
||||||
|
Some(BundleType::AppImage) => "AppImage",
|
||||||
|
Some(BundleType::Deb) => "deb",
|
||||||
|
Some(BundleType::Rpm) => "rpm",
|
||||||
|
Some(BundleType::Msi) => "MSI",
|
||||||
|
Some(BundleType::Nsis) => "NSIS",
|
||||||
|
Some(BundleType::App | BundleType::Dmg) => "app",
|
||||||
|
None => "unpackaged",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn mode(platform: &'static str, msi: bool) -> PackageMode {
|
||||||
|
PackageMode::Automatic { platform, msi }
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn windows_installers_cannot_silently_switch_formats() {
|
||||||
|
let msi = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1";
|
||||||
|
let mut exe = vec![0; 96];
|
||||||
|
exe[..2].copy_from_slice(b"MZ");
|
||||||
|
exe[0x3c] = 64;
|
||||||
|
exe[64..68].copy_from_slice(b"PE\0\0");
|
||||||
|
assert!(verify(&mode("windows-x86_64", true), msi).is_ok());
|
||||||
|
assert!(verify(&mode("windows-x86_64", false), &exe).is_ok());
|
||||||
|
assert!(verify(&mode("windows-x86_64", true), &exe).is_err());
|
||||||
|
assert!(verify(&mode("windows-x86_64", false), msi).is_err());
|
||||||
|
exe[0x3c..0x40].fill(255); // malformed offset cannot panic/wrap
|
||||||
|
assert!(verify(&mode("windows-x86_64", false), &exe).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn linux_requires_the_expected_appimage_arch_and_mac_requires_archive() {
|
||||||
|
let mut image = vec![0; 32];
|
||||||
|
image[..6].copy_from_slice(b"\x7fELF\x02\x01");
|
||||||
|
image[8..11].copy_from_slice(b"AI\x02");
|
||||||
|
image[18..20].copy_from_slice(b"\x3e\x00");
|
||||||
|
assert!(verify(&mode("linux-x86_64", false), &image).is_ok());
|
||||||
|
image[18] = 183; // ARM64 ELF is never an x86_64 update.
|
||||||
|
assert!(verify(&mode("linux-x86_64", false), &image).is_err());
|
||||||
|
for platform in ["darwin-aarch64", "darwin-x86_64"] {
|
||||||
|
assert!(verify(&mode(platform, false), b"\x1f\x8b\x08").is_ok());
|
||||||
|
assert!(verify(&mode(platform, false), b"MSI").is_err());
|
||||||
|
}
|
||||||
|
assert!(verify(&PackageMode::Manual, b"\x1f\x8b\x08").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
//! Signed launcher releases are a separate trust domain from the ShaCraft pack.
|
||||||
|
//! Authenticate the exact metadata bytes before parsing any URLs or versions.
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use minisign_verify::{PublicKey, Signature};
|
||||||
|
use semver::Version;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{collections::BTreeMap, io::Read};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
pub(crate) const RELEASES: &str = "https://github.com/emil28092005/shacraft-launcher/releases";
|
||||||
|
pub(crate) const LATEST: &str =
|
||||||
|
"https://github.com/emil28092005/shacraft-launcher/releases/latest/download/latest.json";
|
||||||
|
pub(crate) const MAX_METADATA: u64 = 32768;
|
||||||
|
pub(crate) const MAX_SIGNATURE: u64 = 2048;
|
||||||
|
pub(crate) const MAX_PACKAGE: u64 = 1024 * 1024 * 1024;
|
||||||
|
const PINNED_KEY: &str = include_str!("../../updater-public-key.txt");
|
||||||
|
pub(crate) fn test_build() -> bool {
|
||||||
|
option_env!("SHACRAFT_UPDATER_TEST_BUILD") == Some("1")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn configured_key() -> Option<&'static str> {
|
||||||
|
let injected = option_env!("SHACRAFT_UPDATER_PUBLIC_KEY").map(str::trim);
|
||||||
|
let pinned = PINNED_KEY.trim();
|
||||||
|
// Explicit CI-only builds may use a disposable real signing key. Release CI
|
||||||
|
// forbids this switch; every such binary exposes its test provenance in status.
|
||||||
|
if test_build() {
|
||||||
|
return injected.filter(|key| key_is_valid(key));
|
||||||
|
}
|
||||||
|
if key_is_valid(pinned) && injected.is_none_or(|key| key == pinned) {
|
||||||
|
Some(pinned)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORMS: [(&str, &str); 4] = [
|
||||||
|
("windows-x86_64", "windows-x86_64-setup.exe"),
|
||||||
|
("linux-x86_64", "linux-x86_64.AppImage"),
|
||||||
|
("darwin-aarch64", "darwin-aarch64.app.tar.gz"),
|
||||||
|
("darwin-x86_64", "darwin-x86_64.app.tar.gz"),
|
||||||
|
];
|
||||||
|
const MANUAL: [(&str, &str); 4] = [
|
||||||
|
("windows-x86_64-msi", "windows-x86_64.msi"),
|
||||||
|
("linux-x86_64-deb", "linux-x86_64.deb"),
|
||||||
|
("darwin-aarch64-dmg", "darwin-aarch64.dmg"),
|
||||||
|
("darwin-x86_64-dmg", "darwin-x86_64.dmg"),
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub(crate) struct Artifact {
|
||||||
|
pub url: String,
|
||||||
|
pub signature: String,
|
||||||
|
pub sha256: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||||
|
pub(crate) struct Release {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub version: String,
|
||||||
|
pub tag: String,
|
||||||
|
pub notes: String,
|
||||||
|
#[serde(rename = "pub_date")]
|
||||||
|
pub pub_date: String,
|
||||||
|
pub platforms: BTreeMap<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;
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
use super::*;
|
||||||
|
use std::{
|
||||||
|
cell::Cell,
|
||||||
|
collections::VecDeque,
|
||||||
|
io::{self, Cursor},
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY: &str = include_str!("../../../tests/fixtures/updater/public-key.txt");
|
||||||
|
const METADATA: &[u8] = include_bytes!("../../../tests/fixtures/updater/latest.json");
|
||||||
|
const SIG: &str = include_str!("../../../tests/fixtures/updater/latest.json.sig");
|
||||||
|
const PACKAGE: &[u8] = include_bytes!("../../../tests/fixtures/updater/package.txt");
|
||||||
|
|
||||||
|
fn release() -> VerifiedRelease {
|
||||||
|
VerifiedRelease::parse(METADATA, SIG, KEY).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn real_tauri_signatures_authenticate_metadata_and_fake_installer_input() {
|
||||||
|
assert!(key_is_valid(KEY));
|
||||||
|
assert!(!key_is_valid("unconfigured"));
|
||||||
|
let release = release();
|
||||||
|
let artifact = &release.release.platforms["linux-x86_64"];
|
||||||
|
let calls = Cell::new(0);
|
||||||
|
install_verified(PACKAGE, artifact, KEY, |bytes| {
|
||||||
|
calls.set(calls.get() + 1);
|
||||||
|
assert_eq!(bytes, PACKAGE);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(calls.get(), 1);
|
||||||
|
// Real signed test data only. This fake installer never executes the fixture.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_metadata_version_platform_or_package_cannot_reach_installer() {
|
||||||
|
for (from, to) in [
|
||||||
|
("0.3.0", "0.9.0"),
|
||||||
|
("darwin-aarch64", "darwin-mips64"),
|
||||||
|
("shacraft-launcher/releases", "other-repository/releases"),
|
||||||
|
] {
|
||||||
|
let changed = String::from_utf8(METADATA.to_vec())
|
||||||
|
.unwrap()
|
||||||
|
.replace(from, to);
|
||||||
|
assert!(VerifiedRelease::parse(changed.as_bytes(), SIG, KEY).is_err());
|
||||||
|
}
|
||||||
|
let release = release();
|
||||||
|
let artifact = &release.release.platforms["linux-x86_64"];
|
||||||
|
let calls = Cell::new(0);
|
||||||
|
let mut corrupt = PACKAGE.to_vec();
|
||||||
|
corrupt[0] ^= 1;
|
||||||
|
assert!(install_verified(&corrupt, artifact, KEY, |_| {
|
||||||
|
calls.set(1);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.is_err());
|
||||||
|
let mut wrong_signature = artifact.clone();
|
||||||
|
wrong_signature.signature = release.release.platforms["windows-x86_64"]
|
||||||
|
.signature
|
||||||
|
.clone();
|
||||||
|
assert!(install_verified(PACKAGE, &wrong_signature, KEY, |_| {
|
||||||
|
calls.set(1);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.is_err());
|
||||||
|
assert_eq!(calls.get(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_version_is_no_update_and_downgrades_or_prereleases_are_rejected() {
|
||||||
|
let release = release();
|
||||||
|
assert!(release.is_newer_than("0.2.0").unwrap());
|
||||||
|
assert!(!release.is_newer_than("0.3.0").unwrap());
|
||||||
|
assert!(release.is_newer_than("0.4.0").is_err());
|
||||||
|
for bad in [
|
||||||
|
"v0.3.0",
|
||||||
|
"0.3.0-rc.1",
|
||||||
|
"0.3.0+other",
|
||||||
|
"01.2.3",
|
||||||
|
"256.0.0",
|
||||||
|
"0.256.0",
|
||||||
|
"0.0.65536",
|
||||||
|
] {
|
||||||
|
assert!(stable_version(bad).is_err(), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_platform_descriptors_bind_repository_tag_filename_size_and_hash() {
|
||||||
|
let release = release();
|
||||||
|
let mut missing = release.release.platforms.clone();
|
||||||
|
missing.remove("darwin-aarch64");
|
||||||
|
assert!(validate_artifacts(&missing, &PLATFORMS, &release.release).is_err());
|
||||||
|
let mut wrong_arch = release.release.platforms.clone();
|
||||||
|
wrong_arch.insert(
|
||||||
|
"darwin-aarch64".into(),
|
||||||
|
release.release.platforms["darwin-x86_64"].clone(),
|
||||||
|
);
|
||||||
|
assert!(validate_artifacts(&wrong_arch, &PLATFORMS, &release.release).is_err());
|
||||||
|
for url in [
|
||||||
|
"https://github.com/other/repo/releases/download/v0.3.0/file",
|
||||||
|
"https://github.com/emil28092005/shacraft-launcher/releases/download/v0.2.0/file",
|
||||||
|
"https://evil.invalid/update",
|
||||||
|
] {
|
||||||
|
let mut wrong = release.release.platforms.clone();
|
||||||
|
wrong.get_mut("linux-x86_64").unwrap().url = url.into();
|
||||||
|
assert!(validate_artifacts(&wrong, &PLATFORMS, &release.release).is_err());
|
||||||
|
}
|
||||||
|
for size in [0, MAX_PACKAGE + 1] {
|
||||||
|
let mut wrong = release.release.platforms.clone();
|
||||||
|
wrong.get_mut("linux-x86_64").unwrap().size = size;
|
||||||
|
assert!(validate_artifacts(&wrong, &PLATFORMS, &release.release).is_err());
|
||||||
|
}
|
||||||
|
let mut wrong = release.release.platforms.clone();
|
||||||
|
wrong.get_mut("linux-x86_64").unwrap().sha256 = "A".repeat(64);
|
||||||
|
assert!(validate_artifacts(&wrong, &PLATFORMS, &release.release).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn github_redirects_cannot_leave_release_hosts_or_downgrade_https() {
|
||||||
|
for url in [
|
||||||
|
LATEST,
|
||||||
|
"https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/latest.json",
|
||||||
|
"https://release-assets.githubusercontent.com/github-production-release-asset/123?sig=test",
|
||||||
|
] {
|
||||||
|
assert!(redirect_allowed(&Url::parse(url).unwrap()), "{url}");
|
||||||
|
}
|
||||||
|
for url in [
|
||||||
|
"http://github.com/emil28092005/shacraft-launcher/releases",
|
||||||
|
"https://github.com/other/repository/releases/latest",
|
||||||
|
"https://release-assets.githubusercontent.com.evil.invalid/file",
|
||||||
|
"https://evil.invalid/file",
|
||||||
|
"https://user@github.com/emil28092005/shacraft-launcher/releases/a",
|
||||||
|
"https://github.com:8443/emil28092005/shacraft-launcher/releases/a",
|
||||||
|
"https://github.com/emil28092005/shacraft-launcher/releases/a#extra",
|
||||||
|
] {
|
||||||
|
assert!(!redirect_allowed(&Url::parse(url).unwrap()), "{url}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounded_reads_reject_oversized_or_interrupted_downloads() {
|
||||||
|
assert!(read_bounded(Cursor::new(b"12345"), 4, |_| {}).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
read_bounded(Cursor::new(b"1234"), 4, |_| {}).unwrap(),
|
||||||
|
b"1234"
|
||||||
|
);
|
||||||
|
struct Broken;
|
||||||
|
impl Read for Broken {
|
||||||
|
fn read(&mut self, _: &mut [u8]) -> io::Result<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.1.7",
|
"version": "0.2.0",
|
||||||
"identifier": "ru.shacraft.launcher",
|
"identifier": "ru.shacraft.launcher",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
@@ -28,7 +28,6 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"createUpdaterArtifacts": true,
|
|
||||||
"targets": "all",
|
"targets": "all",
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
@@ -37,22 +36,18 @@
|
|||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"linux": {
|
"windows": {
|
||||||
"deb": {
|
"wix": {
|
||||||
"depends": [
|
"upgradeCode": "2058b1df-56a1-51ef-bd48-d296479cd59a"
|
||||||
"libwebkit2gtk-4.1-0",
|
},
|
||||||
"libgtk-3-0",
|
"nsis": {
|
||||||
"pkexec"
|
"installMode": "currentUser"
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"updater": {
|
"updater": {
|
||||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEY3NUEyMDIwOUM3QTFFRTYKUldUbUhucWNJQ0JhOTFnUUo4d0Rmc1JxOVdyRElCYTRranJKRzZEYzloRGNXQ09NL1kvN042OEsK",
|
"pubkey": "",
|
||||||
"endpoints": [
|
|
||||||
"https://shacraft.ru/launcher/updates/stable-v2.json"
|
|
||||||
],
|
|
||||||
"windows": {
|
"windows": {
|
||||||
"installMode": "passive"
|
"installMode": "passive"
|
||||||
}
|
}
|
||||||
|
|||||||
-17
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"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
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"manualPackages": {
|
||||||
|
"darwin-aarch64-dmg": {
|
||||||
|
"sha256": "409ed0537cccf2724f405fd0031f6e84718442b263b75513532e03ecc991a678",
|
||||||
|
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUlYSjNaNG55a084WTlCTFlhQTdOdlNNZ3JYYjNaeXZLOThXWERjN3kwWkRtcnNCN0NMVDJBOEpUdVVlS2JJYkpZc3dkdWtyOXQvQ2k3dGJ1bldyRXc4PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLWFhcmNoNjQuZG1nCnRnRlh2dnFUdnJ1MG4vTTF6SExvNzk3ZVpvUkZ5Nk0vdjRiSFpiaHFZUkw5a3N4TkkyODV3RDY0MmJyTXpNdFUwL1NBSlluTG5WeVlMdE1xa3pkWUNnPT0K",
|
||||||
|
"size": 97,
|
||||||
|
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-aarch64.dmg"
|
||||||
|
},
|
||||||
|
"darwin-x86_64-dmg": {
|
||||||
|
"sha256": "70496d49bf410231a2575c384a306023ab5eeaf312d4dd93b6e8aed3e2ee9492",
|
||||||
|
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUJ2biswTzY5QmNNU2hmRHVRV2xpUEtBay94SW1NdHArVnJDQmhLZ3N4UEVQSkxJbG92dmF1bSt4RmJkSm0wOExzNlRDUHJJaVpEUWZvVGoxd0NkNFFrPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLXg4Nl82NC5kbWcKVEFaRkxCUFlSRUQ1T0NOa0doclVucnVLNWw0STcxV05ybkxvcWNrazlPdGVEdU5FTlVlMTFSNFlTUEV1RUNvREZqTFZ0UlN3MTJRcGttQWpxblZUQUE9PQo=",
|
||||||
|
"size": 96,
|
||||||
|
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-x86_64.dmg"
|
||||||
|
},
|
||||||
|
"linux-x86_64-deb": {
|
||||||
|
"sha256": "cefbadc09d008ff4b0f26ee788f524644b7dadf9de4549bd8d841e44ad92e292",
|
||||||
|
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheU9BMWNrS1hCWHdJWlNFczFJNklxMDJsdHMwZ1NFakpxTDExS0xFUXVwWVFRTVh3Q2Vpc0MvUEI4NXZmTjJjMnZaRFoydVk1SHdMSkkwNFRqMkZIUUFFPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfbGludXgteDg2XzY0LmRlYgpjMVZYeVJuUEZWWithQ2lFVHdkWXBqMDA4NGlKT3FYL3djclhyK1hTSE1RVzVrN0p6TVV1V3plOFlKZHQ5K1pzWDMzcmtPYWFPaCtZeEE4WWt6cW5EZz09Cg==",
|
||||||
|
"size": 95,
|
||||||
|
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_linux-x86_64.deb"
|
||||||
|
},
|
||||||
|
"windows-x86_64-msi": {
|
||||||
|
"sha256": "fac4bdeb3c95d4c0a91b5f03215a0dd8f3e676b7e7f9140eef1616d2fefa5c4b",
|
||||||
|
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUdYODluWEtUd2RGNUVyRzNTQWpNV1VoOXpFaXVCZ05SbUpwQTQzYnU2WmI0bzIxVTBTdXFNN2p0Z3NYRndhNmlqTGFzRkZiYllhb0pzbFN4UkMrUUFrPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfd2luZG93cy14ODZfNjQubXNpClZxRlZTL2NobmcreDBLVnc5QzdyZHRkS01NVVZOY2ZpcDNBTWcvUGt6dm15bUNTUzEra09RRDk1Z1VWbmwyU1NjblFXQ0NCcXhTUlpnUEpocWpqQkRRPT0K",
|
||||||
|
"size": 97,
|
||||||
|
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_windows-x86_64.msi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notes": "Synthetic test metadata.",
|
||||||
|
"platforms": {
|
||||||
|
"darwin-aarch64": {
|
||||||
|
"sha256": "73f3eea52be58fb872d2f6423f5cc604b1599403a556a2208173f8b3ea1e66cb",
|
||||||
|
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheVBPN2xlbzEyQ3h2SkRCdzNlUnFxVEhzQjNYRGNNT1B2enc5TUxKQ1U5TDdwaGhkOUxqNTRnZ3FheDN6UE82SWpMR0YvQUZQR255bWRZcTdYYy9qMWdjPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLWFhcmNoNjQuYXBwLnRhci5negp5ckVPVnhvSE1yNitYZm9SRi9KRVNjTWhMVUY3dnNDYmViSHNSWmE2TlFpOVJqd2RxZVJuY0NqVHd6UU5PYUt2NXVQN1JKSHlhZVdiY3JvTjdLcUZCQT09Cg==",
|
||||||
|
"size": 104,
|
||||||
|
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-aarch64.app.tar.gz"
|
||||||
|
},
|
||||||
|
"darwin-x86_64": {
|
||||||
|
"sha256": "548d2042689d797dbcb29f09d206e1c36814880a831a26945bf0c550f2617a64",
|
||||||
|
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheVB5MVRuUU52OEdLZDJHYWhmMUtWaDNQMnM0U0xCcC9sdkE0TWhSK0svNzRuTVBtVW5NUTh6MEQ5amVKd0Vpa1l4cWZ2YzNtWW41Y0RQV0VrTitUbVF3PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzE5CWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfZGFyd2luLXg4Nl82NC5hcHAudGFyLmd6ClFuY3IwOXZKaWsrUTIwMGZmTFdFZmh2K3ZEcDAzYXBpSi8wVnJNS05Zb25YdVgyd1IzLzZud1JqaFNHdXZwa0FpclEyQnplZTFjR1RqNUpzQm9qdUJnPT0K",
|
||||||
|
"size": 103,
|
||||||
|
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_darwin-x86_64.app.tar.gz"
|
||||||
|
},
|
||||||
|
"linux-x86_64": {
|
||||||
|
"sha256": "150e75098117b452b56bfd3925e2c6af03129b47e17be4529f4498b17a9ce9ae",
|
||||||
|
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUh1Znd0d2k0c3VGd085c3N2RWVmZnliem04Tzc4MjBaOE1Sd1NWdU9pNU9zZjlMZWNoRnBxaDN6eGlER0hxTDVyRUtVdXdjOUhWK29oM1hSSC84Qmc4PQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfbGludXgteDg2XzY0LkFwcEltYWdlCmdFZTNDdmt0ckRySnB3NWtBeUdYV3drSVZidmNKZEpsOGptcERGK0VjQmpUUDU0c1ZWV1diTWVTZTlhRzVnUVJFRlVITVNhVGxrbS8rMkdTREtUQUNBPT0K",
|
||||||
|
"size": 100,
|
||||||
|
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_linux-x86_64.AppImage"
|
||||||
|
},
|
||||||
|
"windows-x86_64": {
|
||||||
|
"sha256": "a9773409a6545e98af6a9aad2de2b7d89be257c33af6566bd7ffefc1f443d1be",
|
||||||
|
"signature": "dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUduVjJYSS83QzVIMXRBK0ZabHk1UGxZMUxkNVkzYXE4VXgranlqRnluSlhOdlBBWU5qaTB5MmQySTZ6M3pZMmZHNHI4dkZpRzk2Q2NXWS9GbXJ2N3dVPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIwCWZpbGU6c2hhY3JhZnQtbGF1bmNoZXJfMC4zLjBfd2luZG93cy14ODZfNjQtc2V0dXAuZXhlCmlneXpmSGc0WlM3STgxTHUzbldxdVpqbjhraUp6K1c3djhQSWdmOHh0WTFwMnB2cmIwMHdjMVVOajJKVWR0MTVwTTBYd0NvYUk1OVVuemRUakh3SkJBPT0K",
|
||||||
|
"size": 103,
|
||||||
|
"url": "https://github.com/emil28092005/shacraft-launcher/releases/download/v0.3.0/shacraft-launcher_0.3.0_windows-x86_64-setup.exe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pub_date": "2026-09-09T00:00:00Z",
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"tag": "v0.3.0",
|
||||||
|
"version": "0.3.0"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUVnVElINVRoVDVpb2VGV21XT0wzYXN1WmxxTEhGZHVvYzg3S1FVcUFPVnVqa3FpRXFWbFl2T2FHN2ZmOU1FRVpQR1krREhCcU00RVpXMXYzd2s5UndnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIxCWZpbGU6bGF0ZXN0Lmpzb24KVkxhbGNUZE9NSUpTWlR3L1hDczFTTkFzbjkrK1ZyWE9EeG5xTUVBUnRYcW1kZHphNXVTTEs4OTU0WitORWdQdk9PaXNhVWVvbTFJRHFBeWRtQmdzQXc9PQo=
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ShaCraft synthetic fixture; never execute or install.
|
||||||
|
shacraft-launcher_0.3.0_linux-x86_64.AppImage
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4NUEyNTNEQjlERDZDMkIKUldRcmJOMjVQU1ZheUQ2UzVTN0NHS21ydFp1c1REajVucjlXYnFPK3ZSYWYrQVFDaUgvL3lpQ2UK
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIyODAzRjlGOUFFNDM4QzYKUldUR09PU2FueitBSWhoU1Y4S2VFK21OeWRmamVkMlBreWRjbWdtRmNtbEZrTzMwNE92MDU2d3YK
|
||||||
+35
-23
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { FeedbackDialog, type Feedback } from './components/FeedbackDialog'
|
import { LegacyModsDialog } from './components/LegacyModsDialog'
|
||||||
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,25 +28,32 @@ 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 updater = useLauncherUpdate(busy || session.busy || preferences.saving || session.recoveryCodes.length > 0)
|
const updaterBlocked = busy ? 'Завершите игру или дождитесь окончания работы со сборкой.'
|
||||||
const updateLocked = updater.locksOperations
|
: settingsBlocked ? 'Дождитесь сохранения настроек; при ошибке повторите сохранение.'
|
||||||
|
: session.busy || access === 'loading' ? 'Дождитесь завершения работы с аккаунтом.'
|
||||||
|
: session.linking ? 'Завершите подтверждение игрового ника перед обновлением лаунчера.'
|
||||||
|
: session.recoveryCodes.length ? 'Сначала сохраните коды восстановления аккаунта.'
|
||||||
|
: legacyOpen ? 'Закройте проверку старых модов перед обновлением лаунчера.' : null
|
||||||
|
const updater = useUpdater(updaterBlocked)
|
||||||
|
const updaterRecovery = updater.state.status?.phase === 'error' && !updater.state.status.canRetry
|
||||||
|
const updateLocked = updater.mutating || updater.state.status?.phase === 'ready' || updaterRecovery
|
||||||
const disabled = !desktop || busy || updateLocked || session.busy || access === 'loading' ||
|
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 = updater.state.phase === 'ready' || updater.state.phase === 'restarting' ? 'Перезапустите лаунчер' : 'Обновляем лаунчер…'
|
else if (updateLocked) label = updaterRecovery ? 'Нужно восстановить лаунчер' : 'Обновление лаунчера'
|
||||||
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]}…` : 'Подготовка…'
|
||||||
@@ -59,6 +66,11 @@ 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)
|
||||||
@@ -68,28 +80,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}>
|
<div className="workspace" inert={session.recoveryCodes.length > 0 || legacyOpen}>
|
||||||
<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={selected} status={serverStatus}>
|
<ServerStage server={displayed} status={serverStatus} javaMajor={metadata?.javaMajor}>
|
||||||
<PlayDock server={selected} operation={operation} profile={profile}
|
{(updater.state.status?.phase === 'available' || (updater.state.status?.phase === 'manual' && updater.state.status.availableVersion) || updater.state.status?.phase === 'ready' || updater.mutating || updaterRecovery) &&
|
||||||
|
<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}
|
||||||
onPrimary={primary} onRepair={() => { if (!repairDisabled) void launcher.repair(selected.profileId) }} />
|
onLegacy={() => setLegacyOpen(true)} 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}
|
||||||
session={session} updater={updater} host={launcher.host} java={launcher.java} onClose={closeSettings} />
|
updater={updater} native={desktop}
|
||||||
|
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 }: { session: ReturnType<typeof useAccount>; locked: boolean }) {
|
export function AccountSettings({ session, locked, onOnboard }: { session: ReturnType<typeof useAccount>; locked: boolean; onOnboard?: (nickname: string) => Promise<void> }) {
|
||||||
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,19 +41,25 @@ export function AccountSettings({ session, locked }: { session: ReturnType<typeo
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
{account && !linkedNickname && (
|
{account && !linkedNickname && (
|
||||||
<form noValidate onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
|
<form onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
|
||||||
<label className="text-setting">
|
<label className="text-setting">
|
||||||
<span><strong>Игровой ник</strong><small>Общий ник ShaCraft</small></span>
|
<span><strong>Игровой ник</strong><small>Aeronautics</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>Свободный ник закрепляется за аккаунтом. Для старого игрового ника обратитесь к администратору.</small>
|
<small>Подтвердите владение ником на сервере Aeronautics.</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 ? 'Ожидаем подтверждения…' : busy ? 'Создаём проверку…' : 'Привязать ник'}</span>
|
<span>{session.linking ? 'Ожидаем подтверждения…' : 'Привязать ник через открытую игру'}</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 && <div className="setting-row static"><span>Игровой ник</span><small>{linkedNickname}</small></div>}
|
{account && linkedNickname && <>
|
||||||
|
<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>}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Download, RefreshCw } from 'lucide-react'
|
||||||
|
import { canRunUpdater, updaterPercent } from '../state/updater'
|
||||||
|
import type { UpdaterState } from '../state/updater'
|
||||||
|
|
||||||
|
export interface LauncherUpdateProps {
|
||||||
|
state: UpdaterState
|
||||||
|
native: boolean
|
||||||
|
installedVersion?: string
|
||||||
|
onCheck: () => void
|
||||||
|
onInstall: () => void
|
||||||
|
onRestart: () => void
|
||||||
|
onOpenRelease: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LauncherUpdate({ state, native, installedVersion, onCheck, onInstall, onRestart, onOpenRelease }: LauncherUpdateProps) {
|
||||||
|
const status = state.status
|
||||||
|
const phase = status?.phase
|
||||||
|
const percent = updaterPercent(status)
|
||||||
|
const downloadedBytes = status?.downloadedBytes ?? 0
|
||||||
|
const error = state.error ?? (phase === 'error' ? status?.message : null)
|
||||||
|
const needsRecovery = phase === 'error' && status?.canRetry === false
|
||||||
|
const checking = state.pending?.command === 'check' || phase === 'checking' || state.pending?.command === 'status'
|
||||||
|
const downloading = phase === 'downloading'
|
||||||
|
const progressing = downloading || phase === 'verifying' || phase === 'installing' || state.pending?.command === 'install'
|
||||||
|
const packageFormat = status?.packageFormat === 'development' ? 'для разработки'
|
||||||
|
: status?.packageFormat === 'unpackaged' ? 'без установщика' : status?.packageFormat
|
||||||
|
const titles = {
|
||||||
|
idle: 'Можно проверить новую версию', checking: 'Проверяем обновления…',
|
||||||
|
available: 'Доступно обновление', downloading: 'Скачиваем обновление…',
|
||||||
|
verifying: 'Проверяем подпись обновления…', installing: 'Устанавливаем обновление…',
|
||||||
|
ready: 'Обновление установлено — нужен перезапуск', no_update: 'Установлена актуальная версия',
|
||||||
|
unconfigured: 'Автообновление пока не настроено', manual: 'Обновление пакета вручную', error: 'Не удалось обновить лаунчер',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { native } from '../services/native'
|
||||||
|
import { errorMessage } from '../services/async'
|
||||||
|
import type { LegacyMod } from '../types/launcher'
|
||||||
|
|
||||||
|
export function LegacyModsDialog({ profileId, onClose, onChanged }: { profileId: string; onClose: () => void; onChanged: () => void }) {
|
||||||
|
const [files, setFiles] = useState<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">{server.name.slice(0, 1)}</span>
|
<span className={`server-glyph ${server.id}`} aria-hidden="true">A</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,6 +18,7 @@ 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) {
|
||||||
@@ -41,7 +42,9 @@ 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 ? `${profile.inspection.managedFiles} файлов под контролем` : 'Проверяем локальные файлы'
|
detail = profile?.inspection
|
||||||
|
? `${profile.inspection.upToDate ? 'Проверено файлов сборки' : 'Файлов в сборке'}: ${profile.inspection.managedFiles}`
|
||||||
|
: 'Проверяем локальные файлы'
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -58,7 +61,8 @@ 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="repair-button" onClick={props.onRepair} disabled={props.repairDisabled} aria-label="Проверить файлы"><RotateCcw size={19} /></button>
|
<button className="legacy-button" onClick={props.onLegacy} disabled={props.repairDisabled}>Разобрать моды</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, children }: { server: Server; status: ServerStatus | null; children: ReactNode }) {
|
export function ServerStage({ server, status, javaMajor, children }: { server: Server; status: ServerStatus | null; javaMajor?: number; children: ReactNode }) {
|
||||||
const online = status?.reachable && status.online !== null && status.max !== null
|
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, children }: { server: Server; stat
|
|||||||
<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>Версия {server.id === 'minigames' ? 25 : 21}</dd></div>
|
<div><dt>Java</dt><dd>{javaMajor ? `Версия ${javaMajor}` : 'По подписанной сборке'}</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 { LauncherUpdateSettings } from './LauncherUpdateSettings'
|
import { LauncherUpdate } from './LauncherUpdate'
|
||||||
|
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,11 +14,14 @@ 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, updater, onClose }: SettingsDrawerProps) {
|
export function SettingsDrawer({ open, locked, host, java, preferences, session, onClose, requiredJava, onOnboard, updater, native }: 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(() => {
|
||||||
@@ -28,7 +31,9 @@ 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 = closeButton.current?.closest('aside')?.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), summary')
|
const elements = Array.from(closeButton.current?.closest('aside')?.querySelectorAll<HTMLElement>(
|
||||||
|
'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() }
|
||||||
@@ -47,29 +52,31 @@ 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>
|
||||||
<LauncherUpdateSettings updater={updater} />
|
<LauncherUpdate state={updater.state} native={native} installedVersion={host?.launcherVersion}
|
||||||
|
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} />
|
<AccountSettings session={session} locked={locked || saving} onOnboard={onOnboard} />
|
||||||
<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 ? 'Проверяем…' : 'Проверяется в приложении'
|
||||||
: java?.major === 21 ? 'Java 21 найдена' : java ? `Нужна Java 21 · найдена ${java.major}`
|
: requiredJava === undefined ? 'Требование Java уточняется по сборке'
|
||||||
: 'Лаунчер установит Java 21 автоматически'}</small>
|
: java?.major === requiredJava ? `Java ${requiredJava} найдена` : java ? `Нужна Java ${requiredJava} · найдена ${java.major}`
|
||||||
|
: `Лаунчер установит 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 21 будет управляться лаунчером автоматически.'}</div>
|
<div className="drawer-note">{host ? `Данные лаунчера: ${host.dataDir}` : 'Java будет подобрана по подписанной сборке.'}</div>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ 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 { ServerStage } from './ServerStage'
|
import { LegacyModsDialog } from './LegacyModsDialog'
|
||||||
import { Library } from './Library'
|
import { PlayDock } from './PlayDock'
|
||||||
|
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: () => {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,7 +35,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, /Общий ник ShaCraft/)
|
match(unlinked, /Aeronautics/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('recovery codes render only until explicitly acknowledged', () => {
|
test('recovery codes render only until explicitly acknowledged', () => {
|
||||||
@@ -47,14 +48,49 @@ test('recovery codes render only until explicitly acknowledged', () => {
|
|||||||
equal(renderToStaticMarkup(<RecoveryCodesModal codes={[]} onAcknowledge={() => {}} />), '')
|
equal(renderToStaticMarkup(<RecoveryCodesModal codes={[]} onAcknowledge={() => {}} />), '')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('both profiles remain selectable and display their own runtime requirements', () => {
|
test('onboarding is visible only for a signed-in unlinked account and needs an explicit nickname', () => {
|
||||||
const library = renderToStaticMarkup(<Library selected={servers[1]!} profiles={{}} account={null}
|
const onOnboard = async () => {}
|
||||||
locked={false} native={false} onSelect={() => {}} onSettings={() => {}} />)
|
const unlinked = { username: 'website_login', links: [] }
|
||||||
match(library, /Aeronautics/)
|
const html = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: unlinked }} locked={false} onOnboard={onOnboard} />)
|
||||||
match(library, /Minigames/)
|
match(html, /Установить и войти для подтверждения/)
|
||||||
equal((library.match(/class="server-row/g) ?? []).length, 2)
|
match(html, /<button[^>]*type="button"[^>]*disabled=""[^>]*><span>Установить и войти для подтверждения/)
|
||||||
const stage = (index: number) => renderToStaticMarkup(<ServerStage server={servers[index]!} status={null}>{null}</ServerStage>)
|
match(html, /minLength="3" maxLength="16" pattern="\[A-Za-z0-9_\]\{3,16\}" required=""/)
|
||||||
match(stage(0), /Версия 21/)
|
equal(launchAccess(unlinked), 'link')
|
||||||
match(stage(1), /Версия 25/)
|
|
||||||
match(stage(1), /Fabric 0.19.5/)
|
const signedOut = renderToStaticMarkup(<AccountSettings session={session()} locked={false} onOnboard={onOnboard} />)
|
||||||
|
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,75 +1,86 @@
|
|||||||
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 { LauncherUpdateSettings } from './LauncherUpdateSettings'
|
import { LauncherUpdate } from './LauncherUpdate'
|
||||||
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
|
import { initialUpdaterState } from '../state/updater'
|
||||||
import { initialUpdaterState, updaterReducer, updateBlocksOperations, type UpdaterState } from '../state/updater'
|
import type { UpdaterState } from '../state/updater'
|
||||||
|
import type { UpdaterStatus } from '../types/updater'
|
||||||
|
|
||||||
function render(state: UpdaterState): string {
|
function status(phase: UpdaterStatus['phase'], extra: Partial<UpdaterStatus> = {}): UpdaterStatus {
|
||||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
return { revision: 1, installedVersion: '0.2.0', testBuild: false, packageFormat: 'development', phase, availableVersion: null, releaseNotes: null,
|
||||||
const previousTauri = Object.getOwnPropertyDescriptor(globalThis, 'isTauri')
|
downloadedBytes: 0, totalBytes: null, canRetry: false, message: null, ...extra }
|
||||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { isTauri: true } })
|
}
|
||||||
Object.defineProperty(globalThis, 'isTauri', { configurable: true, value: true })
|
function render(value: UpdaterStatus, extra: Partial<UpdaterState> = {}, native = true) {
|
||||||
try {
|
return renderToStaticMarkup(<LauncherUpdate state={{ ...initialUpdaterState, status: value, ...extra }} native={native}
|
||||||
const updater: ReturnType<typeof useLauncherUpdate> = {
|
onCheck={() => {}} onInstall={() => {}} onRestart={() => {}} onOpenRelease={() => {}} />)
|
||||||
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')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const available = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
|
test('available updater notes are plain text and installation explicitly includes restart', () => {
|
||||||
status: { currentVersion: '0.1.4', supported: true, installationKind: 'deb', version: '0.1.5' } })
|
const html = render(status('available', { availableVersion: '0.3.0', releaseNotes: '<img src=x onerror="alert(1)">\n[Link](https://example.invalid)' }))
|
||||||
|
match(html, /Версия 0\.2\.0/)
|
||||||
test('deb announces system authorization before installation without collecting credentials', () => {
|
match(html, /Новая версия: <strong>0\.3\.0/)
|
||||||
const html = render(available)
|
match(html, /Установить и перезапустить/)
|
||||||
match(html, /Для обновления deb потребуется подтверждение администратора в системном окне/)
|
match(html, /Установка закроет и перезапустит лаунчер/)
|
||||||
match(html, /class="update-primary"><[^>]+.*Обновить<\/button>/)
|
match(html, /<img src=x/)
|
||||||
doesNotMatch(html, /<input|type="password"|Перезапустить лаунчер/)
|
doesNotMatch(html, /<img|<a\s|dangerouslySetInnerHTML|authenticode|notarization/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('deb installation requests system confirmation and prevents duplicate install or check', () => {
|
test('an unconfigured build still reports its installed native version', () => {
|
||||||
const downloading = updaterReducer(available, { type: 'install' })
|
const html = render(status('unconfigured'))
|
||||||
const installing = updaterReducer(downloading, { type: 'progress',
|
match(html, /Версия 0\.2\.0/)
|
||||||
progress: { stage: 'installing', downloadedBytes: 10, totalBytes: 10 } })
|
match(html, /Автообновление пока не настроено/)
|
||||||
const html = render(installing)
|
doesNotMatch(html, /Установить и перезапустить/)
|
||||||
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('cancelled deb authorization remains visible and permits explicit retry without claiming success', () => {
|
test('test updater builds have a visible native-provided notice', () => {
|
||||||
const downloading = updaterReducer(available, { type: 'install' })
|
match(render(status('available', { testBuild: true })), /Тестовая сборка · тестовый канал обновлений/)
|
||||||
const installing = updaterReducer(downloading, { type: 'progress',
|
doesNotMatch(render(status('available')), /Тестовая сборка/)
|
||||||
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('AppImage and older native status retain their installation copy without administrator hints', () => {
|
test('manual Linux packages offer release instructions without a native install button or frontend link', () => {
|
||||||
for (const installationKind of ['appimage', undefined] as const) {
|
const html = render(status('manual', { availableVersion: '0.3.0', packageFormat: 'deb' }))
|
||||||
const status = { ...available.status!, installationKind }
|
match(html, /Версия 0\.2\.0 · deb/)
|
||||||
const downloading = updaterReducer({ ...available, status }, { type: 'install' })
|
match(html, /системный менеджер пакетов/)
|
||||||
const installing = updaterReducer(downloading, { type: 'progress',
|
match(html, /Открыть страницу выпусков/)
|
||||||
progress: { stage: 'installing', downloadedBytes: 10 } })
|
doesNotMatch(html, /Установить и перезапустить|href=/)
|
||||||
const html = render(installing)
|
})
|
||||||
match(html, /Проверяем подпись и устанавливаем/)
|
|
||||||
doesNotMatch(html, /администратора|системном окне/)
|
test('pending game or settings work disables the explicit update action and explains why', () => {
|
||||||
}
|
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, /Повторить проверку|Установить и перезапустить|Перезапустить лаунчер/)
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-7
@@ -8,13 +8,8 @@ export const servers: readonly [Server, ...Server[]] = [
|
|||||||
kicker: 'Основная сборка',
|
kicker: 'Основная сборка',
|
||||||
name: 'Aeronautics',
|
name: 'Aeronautics',
|
||||||
subtitle: 'Строй корабли. Поднимай города в небо.',
|
subtitle: 'Строй корабли. Поднимай города в небо.',
|
||||||
version: '1.21.1 · NeoForge 21.1.248',
|
version: 'Версия уточняется',
|
||||||
loader: 'NeoForge 21.1.248',
|
loader: 'По подписанной сборке',
|
||||||
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',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|||||||
+65
-37
@@ -1,10 +1,15 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { 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 } from '../types/launcher'
|
import type { ShaCraftAccount, LinkChallenge } 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.
|
||||||
@@ -12,12 +17,8 @@ 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())
|
||||||
|
|
||||||
@@ -34,6 +35,44 @@ 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)) {
|
||||||
@@ -53,6 +92,7 @@ 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
|
||||||
@@ -69,6 +109,7 @@ 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)
|
||||||
@@ -84,43 +125,31 @@ 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 (pending.current) return
|
if (!isNative() || pending.current || challenge || !account) return
|
||||||
if (!isNative()) {
|
|
||||||
reportLink('Привязка доступна в приложении лаунчера.', 'error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!account) {
|
|
||||||
reportLink('Войдите в аккаунт ShaCraft, затем повторите привязку.', 'error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
nickname = nickname.trim()
|
|
||||||
if (!isValidNickname(nickname)) {
|
if (!isValidNickname(nickname)) {
|
||||||
reportLink('Ник: 3–16 латинских букв, цифр или _', 'error')
|
setLinkMessage('Ник: 3–16 латинских букв, цифр или _')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pending.current = true
|
pending.current = true
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError(null)
|
setLinkMessage('Создаём проверку…')
|
||||||
reportLink('Проверяем аккаунт и закрепляем ник…')
|
|
||||||
requests.current.invalidate()
|
requests.current.invalidate()
|
||||||
const currentRequest = requests.current.capture()
|
const currentRequest = requests.current.capture()
|
||||||
try {
|
try {
|
||||||
const refreshed = await native.getAccount()
|
const started = await native.startLink(nickname)
|
||||||
if (!currentRequest()) return
|
if (!currentRequest()) return
|
||||||
setAccount(refreshed)
|
acceptChallenge(started)
|
||||||
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) {
|
||||||
if (currentRequest()) reportLink(errorMessage(reason, 'Не удалось начать привязку'), 'error')
|
setLinkMessage(errorMessage(reason, 'Не удалось начать привязку'))
|
||||||
} finally {
|
} finally {
|
||||||
pending.current = false
|
pending.current = false
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
@@ -128,9 +157,8 @@ export function useAccount() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
account, error, busy, recoveryCodes, linkMessage, linking: false,
|
account, error, busy, recoveryCodes, linkMessage, linking: challenge !== null,
|
||||||
feedback, dismissFeedback: () => setFeedback(null),
|
linkedNickname: linkedNickname(account), authenticate, logout, startLink, acceptChallenge,
|
||||||
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