137 changed files with 8888 additions and 9570 deletions
+2
View File
@@ -0,0 +1,2 @@
# Signed updater fixtures must retain their exact committed bytes on every OS.
src-tauri/tests/fixtures/updater/* -text
+48 -59
View File
@@ -1,4 +1,4 @@
name: Cross-platform build
name: Cross-platform build (disposable signatures)
on:
workflow_dispatch:
@@ -8,87 +8,76 @@ on:
permissions:
contents: read
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: ${{ matrix.name }}
name: CI test bundle / ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
include:
- name: Linux x64
- platform: linux-x86_64
os: ubuntu-22.04
args: --bundles appimage,deb
- name: Windows x64
target: x86_64-unknown-linux-gnu
bundles: appimage,deb
- platform: windows-x86_64
os: windows-2022
args: --bundles nsis,msi
- name: macOS Apple Silicon
target: x86_64-pc-windows-msvc
bundles: nsis,msi
- platform: darwin-aarch64
os: macos-15
args: --target aarch64-apple-darwin --bundles app,dmg
- name: macOS Intel
target: aarch64-apple-darwin
bundles: dmg,app
- platform: darwin-x86_64
os: macos-15-intel
args: --target x86_64-apple-darwin --bundles app,dmg
target: x86_64-apple-darwin
bundles: dmg,app
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: dtolnay/rust-toolchain@stable
- name: Install Linux system dependencies
with:
targets: ${{ matrix.target }}
- name: Install Linux desktop dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
# 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 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
# These are build/test artifacts. Release signing happens separately with
# the operator-held key; CI never receives that key or publishes stable.json.
- run: npm run tauri:build -- --config scripts/tauri-unsigned.json ${{ matrix.args }}
- name: Prepare unsigned macOS updater archive
if: runner.os == 'macOS'
- name: Generate disposable CI key (never a production secret)
run: |
python3 - <<'PY'
import json
from pathlib import Path
import tarfile
apps = list(Path('src-tauri/target').glob('*/release/bundle/macos/*.app'))
if len(apps) != 1:
raise SystemExit('Expected exactly one macOS application bundle')
app = apps[0]
version = json.loads(Path('src-tauri/tauri.conf.json').read_text())['version']
architecture = app.parts[2].split('-')[0]
archive = app.parent / f'ShaCraft.Launcher_{version}_{architecture}.app.tar.gz'
with tarfile.open(archive, 'w:gz') as output:
output.add(app, arcname=app.name)
PY
- name: Upload Windows installers
if: runner.os == 'Windows'
uses: actions/upload-artifact@v4
python scripts/release.py ci-key --directory "$RUNNER_TEMP/ci-updater"
python -c "import json,os; open(os.environ['GITHUB_ENV'],'a').write('RELEASE_VERSION='+json.load(open('package.json'))['version']+'\n')"
- name: Build packages and updater bundles
run: npm run tauri:build -- --ci --target '${{ matrix.target }}' --bundles '${{ matrix.bundles }}' --config "$RUNNER_TEMP/ci-updater/updater-build.json"
- name: Collect and verify every test bundle
run: python scripts/release.py collect --bundle 'src-tauri/target/${{ matrix.target }}/release/bundle' --directory ci-packages --platform '${{ matrix.platform }}' --version "$RELEASE_VERSION"
- uses: actions/upload-artifact@v4
with:
name: shacraft-launcher-windows-x64
name: CI-NOT-FOR-RELEASE-${{ matrix.platform }}
if-no-files-found: error
path: |
src-tauri/target/release/bundle/nsis/*.exe
src-tauri/target/release/bundle/msi/*.msi
- name: Upload Linux packages
if: runner.os == 'Linux'
uses: actions/upload-artifact@v4
with:
name: shacraft-launcher-linux-x64
if-no-files-found: error
path: |
src-tauri/target/release/bundle/appimage/*.AppImage
src-tauri/target/release/bundle/deb/*.deb
- name: Upload macOS package
if: runner.os == 'macOS'
uses: actions/upload-artifact@v4
with:
name: shacraft-launcher-${{ matrix.name == 'macOS Apple Silicon' && 'macos-arm64' || 'macos-x64' }}
if-no-files-found: error
path: |
src-tauri/target/*/release/bundle/dmg/*.dmg
src-tauri/target/*/release/bundle/macos/*.app.tar.gz
retention-days: 7
path: ci-packages/*
+13 -26
View File
@@ -13,30 +13,6 @@ concurrency:
cancel-in-progress: true
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:
runs-on: ubuntu-22.04
steps:
@@ -45,12 +21,23 @@ jobs:
with:
node-version: 22
cache: npm
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: dtolnay/rust-toolchain@stable
- name: Install Linux desktop dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf
# 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: 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
+95
View File
@@ -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"
+196
View File
@@ -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
View File
@@ -11,6 +11,4 @@ src-tauri/gen/
*.pfx
*.sig
admission-client/.gradle/
admission-client/build/
graphify-out/
__pycache__/
+49 -159
View File
@@ -4,9 +4,8 @@
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,
network and process-adjacent work. Profiles are **Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21)
and **Minigames** (Minecraft 26.2, Fabric 0.19.5, Java 25). See the Minigames
integration record below and `docs/release-0.1.6.md` for publication evidence.
network and process-adjacent work. The current production profile is
**Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21).
This repository owns the launcher only. The server-side API and published
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
- The only supported remote profile manifest endpoints are the fixed
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest` and
`https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest`.
- The only supported remote profile manifest endpoint is
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest`.
The read-only Aeronautics player-count endpoint
`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.
@@ -32,8 +30,12 @@ payload are in `/root/shacraft` on the ShaCraft host; see
embedded public key and `keyId` **before** parsing the payload.
- `src-tauri/src/manifest.rs` then validates paths, SHA-256, sizes, HTTPS and
allowed ShaCraft hosts. Do not weaken this whitelist.
- `src-tauri/src/profile.rs` downloads to a temporary sibling file, verifies
size + SHA-256, and atomically replaces only launcher-managed files.
- `profile.rs` / `inventory.rs` stage verified files, journal replacements and
retire only previously owned, unchanged managed files. Unknown files are not
silently adopted or deleted. Legacy mod backup requires explicit selection.
- Play/Repair use one `remote::VerifiedSnapshot` from verification through spawn.
`installation_lock.rs` protects the shared game tree across processes and
retains a PID + start-time lease while Minecraft is alive.
- This ShaCraft manifest is the **only** source of truth for which
Minecraft version / NeoForge version / Java major a profile needs
(`Manifest.minecraft`) and for mod/config files. It never supplies a URL
@@ -43,62 +45,29 @@ payload are in `/root/shacraft` on the ShaCraft host; see
control a URL in any of those domains.
- ShaCraft accounts: `src-tauri/src/shacraft_account.rs` talks only to the
hardcoded `https://shacraft.ru` origin. Passwords are never persisted. The
revocable session token is stored locally with mode 600 on Unix and never
passed to Java. The new admission flow uses fixed POST endpoints
`/api/launcher/v2/admission/nickname` and
`/api/launcher/v2/admission/tickets`; redirects are rejected. Neither the
manifest nor the webview can select their origin or URL.
- Free nicknames are claimed directly for the authenticated account. Existing
player names are reserved server-side and assigned by an administrator;
deleting a website account must not make an existing player's name free.
- After installation, `admission.rs` generates an ephemeral Ed25519 key with
the OS CSPRNG. The backend binds its public key to a one-use ticket, current
account session, canonical `aoc` nickname and server access. Only that
returned nickname selects launch identity; `settings.json` is never a fallback.
Ticket and PKCS#8 private key go only in the final Java child's environment
(`SHACRAFT_ADMISSION_TICKET`, `SHACRAFT_ADMISSION_PRIVATE_KEY`). Never put
them in global environment, argv/argfiles, settings, logs or IPC. A fresh
launch obtains a fresh ticket; there is no shared launcher secret.
- Once admission is enforced on Aeronautics, its server mod verifies the
challenge/proof before world entry, and server-side whitelist enforcement
remains a final access boundary. Replace LoginSystem only as part of the
validated `aoc` rollout; other servers are unaffected. Old launchers without
admission proof will be rejected after enforcement. This authenticates an
account's permission, not the integrity of an unmodified launcher binary.
- Application updates are a separate trust domain from Minecraft profiles.
The only channel is `https://shacraft.ru/launcher/updates/stable.json`.
Both release metadata and the installer need a valid Minisign signature
under the separate updater public key embedded in `tauri.conf.json`.
Never reuse the profile signing key, accept unsigned metadata, or allow IPC
to select an update URL, key, version, installer argument or destination.
Downloads require HTTPS without redirects on exact `shacraft.ru`, below
`/downloads/shacraft-launcher/<signed-version>/`, and are bounded to 256 MiB.
Stable versions must increase. The native layer owns every candidate.
- Linux self-update supports AppImage and an installed `sha-craft-launcher`
deb. AppImage preserves executable permissions and uses same-directory
atomic replacement after verification. Deb selects only the signed
`linux-x86_64-deb` entry; retain legacy `linux-x86_64` AppImage metadata for
installed 0.1.3 clients. Never silently switch installation formats.
- Deb elevation uses only `/usr/bin/pkexec --disable-internal-agent` and the
root-owned installed `/usr/bin/shacraft-launcher --shacraft-install-deb`.
This early helper mode never starts GTK/Tauri or account/network code. It
receives bounded metadata/package bytes over stdin, not paths or commands,
and re-verifies both signatures with the embedded key as root. Before the
fixed dpkg installation, require exact package name, architecture and signed
version, root-only staging and monotonic installed-package version; pass
`--refuse-downgrade` to dpkg to close concurrent-update races. No system
password collection, sudo fallback or permissive polkit policy is allowed.
Cancellation, denied authorization and a busy package manager stay distinct.
Windows/macOS use the pinned Tauri installer implementation; the vendored
updater change only exposes construction from already verified metadata to
avoid a second, unbounded remote JSON request. See its patch notes.
Installation holds game/install/account permits until restart. The game
permit lasts until the tracked Java child exits. These are process-local
guards; another launcher process is not a cross-process lock.
- The updater signing private key stays outside Git on the operator's local
machine; CI receives no production key. Publish only verified packages,
public signatures and signed feed. Updater signatures are separate from
Windows Authenticode and macOS code signing/notarization.
revocable session token is stored locally with mode 600 on Unix. At launch,
the nickname is fetched from the verified `aoc` account link; the legacy
nickname in `settings.json` is ignored as an identity source. Server-side
whitelist enforcement and LoginSystem remain the final access-control
boundary, including for old launcher versions. The explicit onboarding command
is the only unlinked launch path: a short-lived server grant goes to the game
child environment only, never IPC responses, argv or files. The signed pack
must contain ShaCraft Game Bridge; it binds the grant to the game session,
requires LoginSystem authentication and an explicit one-time proof command.
Existing links retain legacy provenance; status polling cannot create links.
- Launcher self-update is a separate trust domain in `updater.rs` and
`updater/protocol.rs`: the fixed GitHub repository's `latest.json` bytes are
authenticated with the pinned Tauri/minisign public key before JSON parsing.
Each exact version/platform artifact also requires that signature, SHA-256
and size. Never reuse the ShaCraft mod-manifest key or accept IPC URLs/keys.
- Linux self-update requires the original ordinary AppImage file and the frozen
Tauri `APPDIR` bound to the running `usr/bin/shacraft-launcher`. Extracted or
inherited AppImage context is manual-only; never replace a bare binary.
- `update_guard.rs` retains one launcher-instance OS lock, drains native writes
through `operations::Lifecycle`, and checks the existing detached-game lease.
`launcher-state/pending-update.json` survives installer handoff; only startup
of its exact target version acknowledges it. Never clear it on a timer.
## Layout
@@ -112,9 +81,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see
- `lib.rs` — module/command registration only; `commands/` holds adapters
for account/game/host/preferences/profiles. Unsigned sync/inspect IPC was
removed; only verified remote manifests may drive profile mutations.
- `operations.rs`process-local install/account permits owned by workers.
The account permit covers ticket issuance through Java spawn, preventing
local logout/account switching from racing that handoff.
- `operations.rs`lifecycle and install/account permits owned by workers.
Launch must use the authenticated ShaCraft nickname; no settings fallback.
- `storage.rs` — unique same-directory atomic writes, owner-only Unix files.
- `trusted_http.rs` — HTTPS and exact-host redirect policy per game provider.
- `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.
- `mojang.rs` — vanilla Minecraft trust boundary + the generic
`inheritsFrom` version-JSON merge (shared with NeoForge's profile).
- `neoforge.rs` — runs NeoForge's official installer headlessly.
- `neoforge.rs` / `neoforge_repair.rs` — verified official installer, isolated
processor rebuild, checked embedded JSON and generated-output receipts.
Never hash legacy generated artifacts as an initial trusted baseline.
- `runtime.rs` — Java 21 auto-provisioning via Eclipse Adoptium.
- `msa.rs` — Microsoft/Xbox/Minecraft Services login; see
`MSA_CLIENT_ID`'s doc comment before touching login — it is currently a
placeholder pending ShaCraft's own Azure AD app registration and
Minecraft-API approval.
- `shacraft_account.rs` — local ShaCraft login/registration, session and
nickname claim/admission API; legacy link commands remain for compatibility.
- `admission.rs` — ephemeral key generation, strict ticket response validation,
canonical identity and child-only admission environment.
- `launch.rs` — builds and spawns the actual `java` process; admission secrets
must remain outside its argument substitution and JVM argfile paths.
- `updater.rs`, `commands/updater.rs` — authenticated release metadata,
bounded package download, platform installation and guarded restart.
- `deb_updater.rs` — installed-package checks, one system authentication
prompt and the bounded, signature-verifying non-GUI root helper.
verified nickname-link API.
- `launch.rs` — builds and spawns the actual `java` process.
- `src-tauri/src/settings.rs` — durable local preferences; maintain backward
compatibility with already-written JSON.
- `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
trust domains used to install and run the game itself.
- `.github/workflows/check.yml` — push/PR UI checks and Linux Rust tests.
- `.github/workflows/build.yml` — main-push/manual cross-platform CI artifacts;
updater signing is explicitly disabled there. Local release signing and
atomic feed publication are documented in `docs/launcher-updates.md`.
Ubuntu 22.04 dependency steps use only the runner main Ubuntu source list;
keep APT signature/hash checks and fail on index errors. Vendor PPAs are not needed.
- `.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
@@ -167,14 +136,6 @@ not prove Tauri commands work.
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.
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
@@ -184,74 +145,3 @@ socket to `135.106.154.86:25567`. See architecture and server rollout records.
them. Inspect `git status` before staging.
- Update this file and `docs/launcher-architecture.md` whenever the trust
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.
+22 -103
View File
@@ -17,113 +17,32 @@
approval и живой OAuth-тест. Текущий запуск использует ShaCraft identity.
- [ ] Cold install / repair / update / game exit на чистых Windows/Linux/macOS.
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.
- [ ] Выбор каталога профиля и безопасный reset только managed-файлов.
- [ ] Keychain-хранилище refresh token; cross-process exclusion при необходимости.
- [ ] Keychain-хранилище сессии ShaCraft; OS-lock и lease игры уже реализованы.
- [ ] Динамический каталог и новости; реальный Aeronautics онлайн уже
загружается через фиксированный display-only API. Не имитировать данные.
## Вход по одноразовому разрешению: внедрение 2026-09-10
## Исправления по handoff (исходники, до выкладки)
- [x] Новый native claim закрепляет свободный ник за аккаунтом без входа в игру.
Старые имена резервируются backend и переносятся администратором.
- [x] После установки Rust создаёт Ed25519 ключ через OS CSPRNG и запрашивает
ticket на фиксированном ShaCraft API. Ник для запуска берётся из ticket;
локальные настройки не могут его заменить.
- [x] Ticket и PKCS#8 private key передаются только окружением дочерней Java;
session сайта остаётся в native. В argv, argfile, settings, логах и IPC
секретов нет. Account permit держится до spawn.
- [x] Ошибки и результаты привязки показаны в диалоге; двойной клик не создаёт
параллельных запросов, старого polling нет.
- [x] Локально пройдены 64 Rust-теста (5 live-тестов пропущены), 22 UI-теста,
TypeScript/Vite и сборка Linux x86-64 `tauri:build -- --no-bundle`.
Шесть браузерных сценариев используют только замоканный Tauri IPC.
- [x] Собраны неподписанные AppImage и deb версии 0.1.2 на Ubuntu 26.04;
проверены извлечение AppImage и metadata deb. Работа на старых Ubuntu и
установка пакетов на чистой машине этим не подтверждены.
- [x] Выложены backend, подписанный клиентский мод и серверный мод Aeronautics;
сервер перезапущен и healthy. Whitelist сохранён; LoginSystem заменён только
на `aoc`. Пакеты Linux 0.1.2 опубликованы на сайте, AppImage запущен локально.
- [x] Настоящий изолированный NeoForge: новый ticket допускает в мир, отсутствие
мода/билета и повтор билета отклоняются. Старый LoginSystem на клиенте не мешает.
Подключение дубликата без авторизации не выбивает уже играющего владельца.
- [x] На публичном сервере подключение без мода отклонено до входа в мир;
HTTPS verifier из контейнера, подпись manifest и SHA-256 мода проверены.
Backend Docker: 404 tests + 48 subtests, включая истечение, отзыв session,
чужую identity, whitelist, резервирование имён и атомарное погашение.
- [x] Полный вход в production Aeronautics через установленный лаунчер
подтверждён пользователем 2026-09-10: «Присоединился!». Это пользовательское
подтверждение, а не автоматизированный cold-install тест.
- [ ] Проверить cold install и этот протокол на Windows/macOS, выпустить
подписанные пакеты. Локальная Linux-сборка не подтверждает эти платформы.
- [ ] Удобное переподключение: сейчас использованный или истёкший ticket
требует нового запуска игры из лаунчера; автоматического обновления нет.
- [x] Один signed snapshot и общий межпроцессный lock на Play/Repair.
- [x] Inventory, journal/recovery, retirement старых неизменённых managed-файлов.
- [x] Явный перенос выбранных legacy-модов в резервную копию.
- [x] NeoForge clean rebuild + provenance receipt; непустая порча обнаруживается.
- [x] Отдельный первый вход с серверным grant и одноразовым proof.
- [x] RAM retry сохраняет намерение; неверный JAVA_HOME не скрывает подходящий PATH.
- [x] Версии в интерфейсе берутся из проверенного manifest.
- [ ] Совместная выкладка backend/Game Bridge/подписанного payload и нового лаунчера.
- [ ] Изолированная игровая проверка LoginSystem + hold + proof, затем beta по ОС.
## Самообновление лаунчера 0.1.3: реализация 2026-09-10
- [x] Отдельный канал обновления приложения на фиксированном HTTPS endpoint.
Выделенный публичный ключ проверяет подпись metadata и пакета; версия,
заметки и URL связаны подписью. Redirect, downgrade и произвольные пути
из webview запрещены; размеры metadata и загрузки ограничены.
- [x] Vendored Tauri updater 2.11.0 принимает уже проверенный JSON через
`check_metadata` без второго HTTP-запроса. На Linux AppImage заменяется
атомарно через временный файл в том же каталоге с проверкой подписи и fsync.
- [x] Проверка при старте без автоматической установки; доступны уведомление
о новой версии, ручная проверка, заметки, прогресс, ошибки, повторная попытка
и явный перезапуск. Сбой проверки не блокирует установленный лаунчер.
- [x] Native guards исключают обновление во время игры и конфликтующих
операций. На время установки и до перезапуска заблокированы запуск игры,
ремонт сборки и изменения аккаунта. Для deb и development binary показано
сообщение об установке вручную; версия 0.1.2 требует первого ручного обновления.
- [x] Пройдены 76 Rust-тестов, 28 UI-тестов и 11 тестов publisher;
TypeScript/Vite успешно собраны. Девять браузерных сценариев с mock Tauri IPC
проверяют обновление, ошибки, повтор, блокировки и восстановление состояния;
внешняя сеть и реальные аккаунты в этих сценариях не используются.
- [x] Живой native-прогон скачал 0.1.3 с production HTTPS: повреждение
отклонено без изменения старого файла, подлинный пакет атомарно заменил
временную копию 0.1.2. Исходный AppImage сохранён, хеши проверены.
- [x] Опубликованы Linux 0.1.3 и подписанный stable feed; HTTPS 200,
`Cache-Control: no-store`, подписи и хеши проверены. Ссылка на сайте обновлена.
AppImage установлен в `~/Applications`, добавлен ярлык и проверен запуск.
- [ ] Полный GUI-цикл «Обновить → Перезапустить» проверить на следующем
релизе; текущий прогон проверяет native-установку и запуск пакета отдельно.
- [ ] Проверить установку и самообновление Windows/macOS перед публикацией
пакетов этих платформ; Authenticode/notarization остаются отдельными задачами.
## Обновление DEB 0.1.4: 2026-09-10
- [x] Отдельный подписанный deb entry, определение установленного формата
и сохранение AppImage-совместимости для клиентов 0.1.3.
- [x] Одно системное подтверждение через pkexec. Root helper до запуска GUI
повторно проверяет подписи и точные Package/Version/Architecture, получает
только bounded bytes через stdin и устанавливает пакет из root-only staging.
Пароль не попадает в лаунчер; отмена не открывает запасные окна авторизации.
- [x] Dpkg отказывается от downgrade и сохраняет системную блокировку пакетов.
Ошибки частичной установки не маскируются; автоматических повторов нет.
Read-only inspection имеет ограничение вывода и времени для группы процессов;
работающий dpkg не прерывается таймаутом посреди изменения пакета.
- [x] 32 UI-теста, TypeScript/Vite и 12 браузерных сценариев с mock IPC;
18 publisher-тестов с настоящими minisign и deb, включая переход feed 0.1.3.
- [x] 84 native-теста прошли. Реальный root-helper в изолированном Ubuntu 26.04
прошёл 10 сценариев: установка подписанного 0.1.4, повреждения, неверные
права/временный каталог, занятый dpkg, повтор и защита от downgrade.
Dpkg подтвердил версию, тестовый маркер профиля сохранён. GUI-подтверждение
PolicyKit этим контейнерным прогоном не проверялось; отмена покрыта UI/unit.
- [x] Опубликованы подписанные AppImage/deb 0.1.4, проверены байты feed и deb,
обе кнопки сайта и системная инструкция. Backend: 404 tests + 48 subtests,
Ruff clean. Minecraft не перезапускался, данные аккаунтов не менялись.
Deb 0.1.3 и ниже требуют первого ручного обновления до 0.1.4.
## Связанные серверные риски
Серверный план находится в `/root/shacraft/PLAN.md`. Для admission обязательны
атомарная одноразовая проверка ticket, привязка к текущим session/аккаунту/нику,
проверка до входа в мир и сохранение whitelist как серверного ограничения.
Старые игровые имена нельзя отдавать первому зарегистрировавшемуся; их
резервирование и назначение аккаунтам — отдельный этап миграции. Старый
NoGravity challenge-поток должен быть закрыт при включённом admission, чтобы
он не обходил резервирование имени. Остальные серверные задачи включают
enforcement реферальных правил и очередь повторов whitelist. Не менять
протокол незаметно в клиентском рефакторинге и не считать ticket доказательством
неизменённости клиентского бинарника.
Сопутствующий серверный код содержит read-only status, nonce proof с legacy
migration и durable grant/revoke outbox. Оплата и доставка whitelist — разные
состояния. Реферальные правила остаются отдельной задачей серверного PLAN;
это исправление не меняет условия покупки. Продакшен не изменён.
+12 -24
View File
@@ -3,15 +3,19 @@
Кроссплатформенный Tauri 2 лаунчер для [ShaCraft](https://shacraft.ru/):
React/TypeScript интерфейс, Rust — файлы, сеть и запуск процессов.
Реализованы отдельные профили Aeronautics (Minecraft 1.21.1, NeoForge, Java 21)
и Minigames (Minecraft 26.2, Fabric, Java 25), подписанная синхронизация файлов,
проверка/восстановление модов и конфигурации, установка игры и Java, настройки
памяти, обработка установки/запуска/выхода. Minigames опубликован в 0.1.6;
проверки и результаты публикации — в [записи релиза](docs/release-0.1.6.md).
Реализованы подписанная синхронизация Aeronautics, проверка/восстановление
модов и конфигурации, Java discovery/provisioning, bootstrap Minecraft и
NeoForge, настройки памяти, обработка установки/запуска/выхода. Проверка
сборки теперь восстанавливает и игровые файлы; старые неизменённые managed-моды
убираются в резервную копию, неизвестные моды разбираются явно пользователем.
Вход выполняется через аккаунт ShaCraft — тот же, что на сайте. Игровой ник
общий для обоих профилей и берётся только из подтверждённой привязки `aoc`.
Тикет входа привязан к выбранному серверу; локальные настройки не выбирают личность. Пароли не сохраняются; сессию можно отозвать.
берётся только из подтверждённой привязки Aeronautics, а не из редактируемых
локальных настроек. Пароли не сохраняются; сессию можно отозвать.
Для первого входа без привязки добавлено отдельное действие в настройках
аккаунта: установка → серверное разрешение → LoginSystem → одноразовая команда
подтверждения. Оно требует совместной выкладки backend, Game Bridge и
подписанного модпака; наличие исходников не означает публикацию этого сценария.
Microsoft OAuth-модуль сохранён отдельно, но не используется текущим
сценарием запуска; для его активации потребуются client ID и API approval.
@@ -39,30 +43,14 @@ cargo test --locked --manifest-path src-tauri/Cargo.toml
Build включает строгий TypeScript. GitHub Actions проверяет UI и Rust на
push/PR; workflow на main-push/ручном запуске собирает Windows x64, Linux x64, macOS Intel
и Apple Silicon и сохраняет неподписанные артефакты. Релизный оператор
подписывает проверенные пакеты и metadata локальным ключом; CI его не получает.
и Apple Silicon и сохраняет артефакты. Подпись релиза/автообновления ещё впереди.
Используемые 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.
- [Trust boundaries](docs/game-trust-boundary.md) — доверенные источники игры.
- [Manifest](docs/manifest-v1.md) — подписанный контракт модпака.
- [Обновления](docs/launcher-updates.md) — подпись, публикация и восстановление.
- [PLAN.md](PLAN.md) — ограничения и следующие шаги.
- [AGENTS.md](AGENTS.md) — инструкции для следующего разработчика/агента.
-47
View File
@@ -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.154.86:25568`, 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.
-37
View File
@@ -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
-9
View File
@@ -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
-248
View File
@@ -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" "$@"
-82
View File
@@ -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%
-8
View File
@@ -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"));
}
}
@@ -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,32 +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("135.106.154.86",25568,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
View File
@@ -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
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
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.
## Generated NeoForge artifacts and Java selection
Minecraft 26.2's official version metadata requires Java 25. `runtime.rs`
already provisions a separate Adoptium Java 25 runtime without changing the
Aeronautics Java 21 runtime. Client companion mods and Fabric API are separately
approved ShaCraft managed files in the signed Minigames profile.
NeoForge 21.1.248's actual installer does not publish processor-output hashes.
The launcher therefore validates the installer SHA-256, extracts its expected
version JSON and a scoped processor recipe, verifies the vanilla input against
Mojang, and runs that installer in a fresh staging tree. Generated JARs must
be readable ZIPs with valid entries. A receipt commits the generated hashes,
installer digest and recipe identity only after successful verified promotion.
This is provenance from a trusted computation, not independent publisher
checksums for generated files. Later corruption invalidates the receipt and
triggers clean reconstruction; a nonempty old file is never enough. Interrupted
promotion has no valid receipt and is repaired on the next preparation.
Only recipe-scoped generated artifacts are promoted; user profile files are
outside this tree. Staging can remain after an abrupt kill and is never trusted
as an installation. The receipt currently lives under the shared game cache.
Java discovery evaluates JAVA_HOME and PATH candidates independently for the
exact required major. A wrong-major JAVA_HOME no longer hides a valid PATH
installation. Only if no matching candidate exists is a managed runtime
provisioned. The UI reads required versions from verified profile metadata.
+167 -265
View File
@@ -2,43 +2,37 @@
## 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 +
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
admission implementation claims a free nickname for that account and requests
a one-use permission immediately before launching Java. The game identity
comes only from the canonical Aeronautics nickname in that permission; the
legacy editable nickname setting is not trusted at launch.
The backend and admission mod were deployed to Aeronautics on 2026-09-10;
the server is healthy with whitelist enforcement retained. Real isolated
NeoForge connections verified successful admission, absent/replayed proof
rejection and protection of an online player from duplicate login. A public
production connection without the mod was rejected before world entry.
This does not certify a full cold installation or Windows/macOS operation.
signs in with the same local ShaCraft account used on the website. The game
identity for ordinary Play is derived only from that account's verified Aeronautics nickname;
the legacy editable nickname setting is not trusted at launch.
The interface also shows a live Aeronautics player count from the fixed,
read-only `https://shacraft.ru/api/online/aoc` endpoint. It is display-only:
the result never controls files, versions, URLs, or the launch command.
Version 0.1.3 adds signed application updates, separate from modpack sync.
Version 0.1.4 adds installed deb updates with system administrator confirmation.
The first AppImage upgrade from 0.1.2 and deb upgrade from 0.1.3 are manual.
Windows/macOS 0.1.5 packages are published; actual desktop installation tests remain pending.
Not yet implemented: a user-selectable profile directory and a "reset managed
files only" recovery action. OS code signing/notarization is separate from the
updater signatures and is not certified by this implementation.
The launcher also checks signed GitHub releases for its own updates. AppImage,
Windows x64 installers and native Intel/Apple Silicon macOS app bundles use
explicit install-and-restart; Debian packages use manual package management.
Production publication and OS signing/notarization require operator setup;
see `updater-release.md`. Version 0.1.1 has no updater and must be upgraded
manually once. User-selectable profile directories and managed-only reset
remain unimplemented.
## Data flow
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
ShaCraft manifest (mods/config + which MC/loader/Java version to use)
signed-manifest endpoint -> Ed25519 verification (remote.rs)
-> manifest schema + URL/path validation (manifest.rs)
-> temporary download, SHA-256 verification, atomic replacement (profile.rs)
-> inventory reconciliation, verified staging, durable apply journal (profile.rs)
Game itself (never controlled by the manifest above)
Mojang version manifest -> SHA-1-verified version JSON (mojang.rs)
@@ -46,14 +40,9 @@ Game itself (never controlled by the manifest above)
-> NeoForge's own installer, run headlessly (neoforge.rs)
-> generic inheritsFrom merge of the two version JSONs (mojang.rs)
-> SHA-1-verified merged libraries + platform natives (mojang.rs)
Admission (fixed ShaCraft account API; never controlled by a manifest)
native website session -> direct free-nickname claim or admin migration
-> after installation: OS CSPRNG -> ephemeral Ed25519 key (admission.rs)
-> public key + bearer session -> one-use ticket for canonical aoc nickname
-> deterministic offline UUID for that nickname (session.rs)
-> Java with merged classpath/args + child-only ticket/private-key environment
-> client mod signs server challenge; server validates before world entry
-> verified ShaCraft account link (shacraft_account.rs)
-> deterministic offline UUID for the linked nickname (session.rs)
-> java process spawned with the merged classpath/args (launch.rs)
```
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
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
written to disk. The admission private key and ticket are ephemeral native
values and are not persisted. None of these directories should be assumed to
written to disk. None of these should be assumed to
be the system `.minecraft` directory.
## Aeronautics contract
@@ -79,81 +67,18 @@ be the system `.minecraft` directory.
- ShaCraft download files: HTTPS only, exact hosts `shacraft.ru` and
`cdn.shacraft.ru`.
- Account API origin: fixed `https://shacraft.ru`; redirects are rejected.
- Launch identity: the canonical `aoc` nickname returned with the admission
ticket. Local nickname edits cannot select an identity.
## Admission contract (implementation: 2026-09-10)
Both endpoints use the fixed account API origin, HTTPS without redirects and
the native website session as bearer authentication. The session is never
passed to the client mod or Java process.
`POST /api/launcher/v2/admission/nickname` accepts
`{server_id: "aoc", mc_username: "Chosen_Name"}` and returns the existing
account shape `{username, links}`. The backend atomically assigns a free name
to that account. Existing player names remain reserved and require explicit
administrator migration. The launcher no longer asks the player to enter the
game to complete this claim. Legacy challenge IPC remains available to older
flows, but an admission-enabled backend must block those legacy endpoints
from bypassing reserved-name ownership.
After installation completes, the native launcher generates a fresh Ed25519
key using the OS CSPRNG and calls
`POST /api/launcher/v2/admission/tickets` with
`{server_id: "aoc", public_key: "<standard base64 raw 32-byte public key>"}`.
The response is `{ticket_id, mc_username, server_id, expires_in_seconds}`.
The native boundary requires a canonical 43-character base64url ticket ID,
an ASCII Minecraft nickname of 316 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.
- Launch identity: the most recently verified `aoc` nickname returned by the
authenticated account API. Local nickname edits cannot select an identity.
## Planned but not implemented
1. User-selectable profile directory and structured launcher logs.
2. "Reset managed files only" recovery action that doesn't touch player
worlds/screenshots/resourcepacks.
3. Windows Authenticode/macOS signing-notarization and cross-platform release
installation testing.
3. Production release credentials/protected environments and OS beta validation.
4. Cancellation, structured logs and a full cold-install/recovery beta on
every target OS. Install progress reports bytes or installer work counts
depending on the stage; these units are not interchangeable.
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.
@@ -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
permits in `operations.rs` stay owned by blocking workers until completion.
ShaCraft sessions have a separate gate from the retained Microsoft module.
These are process-local guards, not cross-process locks or cancellation.
An additional OS file lock in `installation_lock.rs` covers all profiles and
the shared game installation across processes. It remains held by the child
watcher; a durable PID/start-time lease also protects a game that outlives its
launcher. This is exclusion, not cancellation.
`storage.rs` provides unique temporary files and atomic replacement; Unix
session files are created owner-only. Windows keeps a recoverable replacement
fallback if the OS refuses direct replacement. `trusted_http.rs` constrains provider
@@ -178,184 +106,158 @@ URLs and redirects. Manifest profile identity, size, signature, portable
paths and existing symlinks are checked before managed file writes.
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
`npm test` covers asynchronous helpers and state transitions;
`npm run build` runs strict TypeScript before Vite. `cargo test --locked`
covers native policy and storage. Push/PR CI repeats checks on Linux.
The package workflow runs on main pushes or manually and builds Windows
x64, Linux x64 and both macOS architectures with named artifacts.
CI packages are unsigned build artifacts. Signed updater publication is a
separate local operator step. Native cold-install and launch tests are required
before calling a platform release-ready.
The local admission checkpoint passed 64 Rust tests (5 live tests ignored),
22 UI unit tests, TypeScript/Vite build and a Linux x86-64 release build with
`npm run tauri:build -- --no-bundle`. Six browser scenarios with mocked Tauri
IPC covered invalid nicknames, deleted sessions, reserved-name errors,
successful claims, duplicate clicks and a session revoked during a claim.
They also checked modal feedback, Escape preserving the settings drawer and
the absence of legacy link polling. These checks used no production account
or real Minecraft connection. The Linux output is a dynamically linked
binary, not evidence of Windows/macOS support testing.
Version 0.1.2 also produced unsigned Linux amd64 AppImage and deb packages with
`npm run tauri:build -- --bundles appimage,deb`. AppImage extraction and the
deb's version/architecture metadata were checked without running the app or
installing the package. The build host was Ubuntu 26.04; do not claim support
for older Ubuntu releases from this build. For local AppImage packaging,
linuxdeploy's GTK plugin needs `librsvg-2.0.pc` from the matching `librsvg2-dev`
package. Extracting that package into a temporary build directory and setting
`PKG_CONFIG_PATH` supplied the missing metadata without changing host packages.
The 0.1.3 updater checkpoint passed 76 native tests (6 live tests ignored),
28 UI tests, 11 publisher tests with real minisign and TypeScript/Vite build.
Nine browser scenarios used mocked IPC. The signed Linux AppImage/deb were
published on 2026-09-10 with a signed stable feed; feed bytes, signatures and
public HTTPS responses were verified. A separately invoked live native test
downloaded the production release, rejected corrupted bytes without changing
the old file, atomically updated a temporary copy of 0.1.2 and compared hashes.
The original source AppImage was retained. The installed 0.1.3 AppImage was
then started from `~/Applications` and its captured runtime paths verified.
This is not a full GUI update/restart cycle or a Windows/macOS installation test.
The Linux build host remains Ubuntu 26.04.
The 0.1.4 checkpoint passed 84 native tests (6 ignored), 32 UI tests and 18
publisher tests; 12 browser scenarios use mocked IPC. In a disposable Ubuntu
26.04 Docker container without network or production mounts, the actual signed
deb helper passed 10 scenarios: unprivileged invocation, truncated/trailing
input, damaged metadata/package, dpkg lock, unsafe temporary directory,
successful installation, replay and downgrade refusal. The fixture installed
the genuine old 0.1.3 package and bootstrapped the new verifier binary over its
package record; it then installed the genuine signed 0.1.4. It did not relabel
signed versions. Dpkg reported 0.1.4 and a fixture profile marker survived.
This tests the elevated helper and dpkg, not a real desktop PolicyKit dialog.
Cancellation/error rendering is covered by unit/browser scenarios. Published
metadata and deb bytes match the locally verified files; both website download
buttons target 0.1.4. No user host package installation was performed for QA.
The live native AppImage smoke also passed against the published 0.1.4 feed,
including corruption rejection and replacement of only a temporary source copy.
covers native policy and storage. Push/PR CI repeats checks on Linux. Ubuntu 22.04
CI installs desktop dependencies from the runner main distro source list only,
without indexing unrelated vendor repositories. APT signature/hash checks remain
mandatory, and any distro index failure stops installation.
The package workflow builds and checks Windows x64, Linux x64 and both macOS
architectures with disposable test signing keys. These artifacts cannot be
published as production updater releases. Separate protected workflows assemble
a complete signed release as a draft, re-verify its assets and only then publish.
Native cold-install, real self-update and launch tests are required before
calling a platform release-ready. CI package builds do not prove those flows.
## 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,
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.
`profiles/.<profile-id>.shacraft-state/` is outside the payload root. It stores
`inventory.json`, `pending.json`, transaction staging/backups/receipts and
explicit legacy-mod backups. The inventory records only files the launcher
actually wrote, with size/hash/policy and signed snapshot digest. An existing
file that already matches is usable, but is not silently claimed as owned.
Seed files are never owned for later removal.
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
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.
`updater/protocol.rs` pins `github.com/emil28092005/shacraft-launcher/releases`.
The exact raw `latest.json` response is verified using its detached minisign
signature and the committed updater public key before versions, URLs or notes
are parsed. This key is independent of ShaCraft's Ed25519 mod manifest. Signed
metadata binds a stable version and tag to an exact set of four platform and
four manual package descriptors; every descriptor has an exact repository/tag/
filename, size, SHA-256 and Tauri signature. HTTPS redirects are restricted to
that repository and GitHub's release asset CDN. Stable downgrades, unknown
platforms, missing signatures and incomplete metadata fail closed.
## 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
`https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest` and
its display-only count at `https://shacraft.ru/api/online/minigames`.
Aeronautics remains a separate profile and keeps its previous managed files.
Minigames uses Minecraft 26.2, Fabric Loader 0.19.5, Java 25 and its own
`profiles/minigames` game directory. The signed payload supplies Fabric API
0.160.0+26.2 and `mods/shacraft-admission-client-0.1.0.jar`; it never supplies
Paper, the server plugins, maps, credentials or game-download URLs.
Checks run once per application UI lifecycle and on explicit request. They do
not install automatically. The settings drawer shows installed/available
versions, plain-text notes, progress, actionable errors and retry. The explicit
install button includes restart. UI session recovery codes, unsaved/failed
settings and account/game operations inhibit that action; native permits are
the final authority for concurrent writes. Preferences, sessions and game data
live outside the executable and are not migrated or erased by the updater.
`fabric.rs` obtains an exact parent/loader profile from fixed Fabric metadata,
checks its identity and KnotClient entry point, and converts bounded Maven
library entries into the existing verified library contract. Each artifact
URL is constructed from a validated coordinate below fixed Fabric Maven;
metadata-supplied alternative origins are rejected. SHA-1 and size come from
Fabric metadata, or the same Maven's hash sidecar and HEAD for the loader jar.
Java provisioning already accepts exactly Java 25. Automatic installation on
a platform still requires a real cold-install check on that platform.
`launcher-state/instance.lock` is a process-lifetime OS lock, preventing an idle
second cooperating launcher from retaining old code during replacement. All
native account/settings/game writes hold shared lifecycle permits. Replacement
holds the exclusive permit and `installation-state/writer.lock`, which also
checks a game process's durable PID/start-time lease. A normal window close is
inhibited during download/install. Worker permits survive a dropped IPC future.
Before invoking the platform installer, `pending-update.json` records current
and target versions. The Windows plugin hands off and exits; the marker remains.
Only startup of the exact target version clears it. Old versions and indeterminate
installer failures block native mutations and direct the user to manual recovery.
A corrupt marker opens recovery diagnostics and latches the mutation/launch ban
until application restart, even if that file is removed while the UI is open.
No guessed installer timeout
releases the gate. This cannot retroactively make old 0.1.1 binaries cooperate
with these locks.
Both profiles claim/display the canonical `aoc` nickname. Only the native
profile mapping determines ticket `server_id` (`aoc` or `minigames`), and the
response must match that exact server. The backend is responsible for shared
access checks at both issuance and redemption. Existing account sessions and
settings need no migration. No copied subscription/whitelist grant is trusted.
Minigames adds native `--quickPlayMultiplayer 135.106.154.86:25568` at launch.
The client-only Fabric companion validates the actual socket target and signs
the existing configuration challenge with `minigames` in the transcript.
Paper performs verification before entry; its early duplicate UUID gate must
run before vanilla would evict the existing player. There is no proxy and no
client-side shared secret. One ticket is used for one game connection; a fresh
launcher start is required after expiry, consumption or a failed proof attempt.
The integration is staged until server authentication, signed profile payload,
and a newer signed launcher release are deployed and checked together. The
existing public 0.1.5 binary cannot select the new profile by a website-only
catalog change. Preserve both catalog entries when publishing either profile.
First metadata/signature and artifact reads are bounded. Tauri updater 2.11.0
requires its own second check to construct a private installer context; that
check uses the fixed version endpoint, HTTPS host policy and a 30-second timeout,
and its parsed metadata must equal the previously authenticated document.
The plugin's secondary response has no byte-limit API, leaving a memory-use
risk if that trusted release endpoint serves an unexpectedly large response.
Immediately before install, native code re-verifies artifact size/hash/signature;
`Update::install` alone does not perform signature verification.
-266
View File
@@ -1,266 +0,0 @@
# 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/).
-20
View File
@@ -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.
-117
View File
@@ -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.
+307
View File
@@ -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,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
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "shacraft-launcher-ui",
"version": "0.1.6",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shacraft-launcher-ui",
"version": "0.1.6",
"version": "0.2.0",
"dependencies": {
"@tauri-apps/api": "2.11.1",
"lucide-react": "1.41.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "shacraft-launcher-ui",
"license": "MIT",
"private": true,
"version": "0.1.6",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
-1
View File
@@ -1 +0,0 @@
__pycache__/
-360
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
/target/
+23
View File
@@ -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",
]
+9
View File
@@ -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"
+32
View File
@@ -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
}
}
}
+415
View File
@@ -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)
+115
View File
@@ -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()
+237
View File
@@ -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)
+184
View File
@@ -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()
+228
View File
@@ -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)
+14
View File
@@ -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
+531
View File
@@ -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()
-5
View File
@@ -1,5 +0,0 @@
{
"bundle": {
"createUpdaterArtifacts": false
}
}
-287
View File
@@ -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()
+101 -30
View File
@@ -2093,6 +2093,15 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -2258,6 +2267,16 @@ dependencies = [
"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]]
name = "objc2-io-surface"
version = "0.3.2"
@@ -3361,13 +3380,11 @@ dependencies = [
[[package]]
name = "shacraft-launcher"
version = "0.1.6"
version = "0.2.0"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",
"flate2",
"getrandom 0.3.4",
"libc",
"md-5",
"minisign-verify",
"reqwest 0.12.28",
@@ -3377,14 +3394,14 @@ dependencies = [
"serde_json",
"sha1",
"sha2",
"sysinfo",
"tar",
"tauri",
"tauri-build",
"tauri-plugin-updater",
"tempfile",
"time",
"url",
"zeroize",
"zip 2.4.2",
"zip",
]
[[package]]
@@ -3615,6 +3632,20 @@ dependencies = [
"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]]
name = "system-deps"
version = "6.2.2"
@@ -3662,7 +3693,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -3744,7 +3775,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -3828,6 +3859,8 @@ dependencies = [
[[package]]
name = "tauri-plugin-updater"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606"
dependencies = [
"base64 0.22.1",
"dirs",
@@ -3853,7 +3886,6 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
"zip 4.6.1",
]
[[package]]
@@ -3878,7 +3910,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -3903,7 +3935,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
"wry",
]
@@ -4672,7 +4704,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
@@ -4696,7 +4728,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.20",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -4752,11 +4784,23 @@ version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-collections 0.2.0",
"windows-core 0.61.2",
"windows-future",
"windows-future 0.2.1",
"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]]
@@ -4768,6 +4812,15 @@ dependencies = [
"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]]
name = "windows-core"
version = "0.61.2"
@@ -4802,7 +4855,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"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]]
@@ -4849,6 +4913,16 @@ dependencies = [
"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]]
name = "windows-result"
version = "0.3.4"
@@ -4987,6 +5061,15 @@ dependencies = [
"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]]
name = "windows-version"
version = "0.1.7"
@@ -5218,7 +5301,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -5355,18 +5438,6 @@ dependencies = [
"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]]
name = "zlib-rs"
version = "0.6.7"
+8 -12
View File
@@ -1,6 +1,6 @@
[package]
name = "shacraft-launcher"
version = "0.1.6"
version = "0.2.0"
description = "ShaCraft Minecraft launcher"
authors = ["ShaCraft"]
license = "MIT"
@@ -20,21 +20,17 @@ sha1 = "0.10"
sha2 = "0.10"
md-5 = "0.10"
base64 = "0.22"
ed25519-dalek = { version = "2", features = ["pkcs8"] }
getrandom = "0.3"
zeroize = "1"
libc = "0.2"
tempfile = "3"
ed25519-dalek = "2"
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"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"] }
flate2 = "1"
tar = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
sysinfo = { version = "0.39.6", default-features = false, features = ["system"] }
[dev-dependencies]
tauri = { version = "2", features = ["test"] }
tauri-plugin-updater = { version = "=2.11.0", default-features = false, features = ["rustls-tls"] }
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"] }
+3
View File
@@ -1,3 +1,6 @@
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()
}
-244
View File
@@ -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);
}
}
+6
View File
@@ -30,8 +30,10 @@ pub(crate) fn start_microsoft_login(
state: State<'_, LauncherOperations>,
) -> Result<(), String> {
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.account.acquire("Account operation")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let login = || -> Result<msa::MinecraftProfile, 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>,
) -> Result<Option<msa::MinecraftProfile>, String> {
let data_dir = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?;
let permit = state.account.acquire("Account operation")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let Some(refresh_token) = msa::load_refresh_token(&data_dir) else {
return Ok(None);
@@ -102,8 +106,10 @@ pub(crate) async fn logout(
state: State<'_, LauncherOperations>,
) -> Result<(), String> {
let data_dir = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?;
let permit = state.account.acquire("Account operation")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
msa::clear_account(&data_dir)
})
+258 -182
View File
@@ -1,7 +1,7 @@
use super::data_dir;
use super::{data_dir, profiles::ProfileMetadata, shacraft::resolve_identity};
use crate::{
fabric, java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
settings, shacraft_account,
installation_lock::InstallationLock, java, launch, manifest, mojang, neoforge,
operations::LauncherOperations, profile, remote, runtime, session, settings, shacraft_account,
};
use reqwest::blocking::Client;
use serde::Serialize;
@@ -15,135 +15,158 @@ pub(crate) struct InstallProgress {
current_bytes: u64,
total_bytes: u64,
}
fn progress(app: &AppHandle, stage: &'static str) -> mojang::ProgressCallback {
let app = app.clone();
Arc::new(move |current, total| {
let _ = app.emit(
"game-install-progress",
InstallProgress {
stage,
current_bytes: current,
total_bytes: total,
},
);
})
}
/// Resolves the vanilla + (if any) loader version JSONs for `manifest` and
/// merges them, ensuring a Java runtime and (for NeoForge profiles) running
/// the installer along the way. Shared by `ensure_game_installed` and
/// `launch_game` so both always agree on exactly what "installed" means.
/// `on_progress` is forwarded to the NeoForge installer when one runs;
/// callers that don't display progress (e.g. `launch_game`, which only
/// hits this after `ensure_game_installed` already installed everything)
/// pass a no-op callback.
fn resolve_merged_version(
client: &Client,
manifest: &manifest::Manifest,
java_executable: &Path,
java: &Path,
game_dir: &Path,
cache_dir: &Path,
on_progress: &mojang::ProgressCallback,
) -> Result<mojang::MergedVersion, String> {
let mojang_manifest =
mojang::fetch_version_manifest(client).map_err(|error| error.to_string())?;
let vanilla_entry = mojang::find_version(&mojang_manifest, &manifest.minecraft.version)
.ok_or_else(|| {
format!(
"Mojang does not list Minecraft version {}",
manifest.minecraft.version
)
})?;
let vanilla =
mojang::fetch_version_json(client, vanilla_entry).map_err(|error| error.to_string())?;
let catalog = mojang::fetch_version_manifest(client).map_err(|e| e.to_string())?;
let entry = mojang::find_version(&catalog, &manifest.minecraft.version)
.ok_or_else(|| format!("Mojang does not list {}", manifest.minecraft.version))?;
let vanilla = mojang::fetch_version_json(client, entry).map_err(|e| e.to_string())?;
// The installer may reuse an existing vanilla JAR without verifying it.
// Establish provider integrity BEFORE any NeoForge processor uses that input.
mojang::ensure_client_jar(
client,
game_dir,
&vanilla.id,
&vanilla
.downloads
.as_ref()
.ok_or("Vanilla client download metadata is missing")?
.client,
)
.map_err(|e| e.to_string())?;
if manifest.minecraft.loader.kind == "neoforge" {
let installer_client = neoforge::http_client().map_err(|error| error.to_string())?;
let neoforge_version = neoforge::ensure_client_installed(
let installer_client = neoforge::http_client().map_err(|e| e.to_string())?;
let loader = neoforge::ensure_client_installed(
&installer_client,
java_executable,
java,
game_dir,
cache_dir,
&manifest.minecraft.loader.version,
&vanilla,
on_progress,
)
.map_err(|error| error.to_string())?;
mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string())
} 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())
.map_err(|e| e.to_string())?;
mojang::merge_versions(&vanilla, Some(&loader)).map_err(|e| e.to_string())
} else {
mojang::merge_versions(&vanilla, None).map_err(|error| error.to_string())
mojang::merge_versions(&vanilla, None).map_err(|e| e.to_string())
}
}
/// Downloads and installs everything needed to run `profile_id`: the
/// exact Minecraft/loader version the ShaCraft-signed manifest specifies,
/// a Java runtime if none is already usable, and game assets. Emits
/// `game-install-progress` throughout with real progress for every stage:
/// download bytes for Java, installer-confirmed library/processor counts
/// for NeoForge, and download bytes for libraries/assets.
/// Internal stages receive the same verified snapshot; none can refetch it.
fn prepare(
directory: &Path,
snapshot: &remote::VerifiedSnapshot,
progress: &impl Fn(&'static str) -> mojang::ProgressCallback,
) -> Result<
(
mojang::MergedVersion,
java::JavaInstallation,
profile::ProfileInspection,
),
String,
> {
let manifest = &snapshot.manifest;
let root = directory.join("profiles").join(&manifest.id);
progress("mods")(0, 0);
profile::sync_snapshot(&root, snapshot).map_err(|e| e.to_string())?;
let inspection = profile::inspect(&root, manifest).map_err(|e| e.to_string())?;
if !inspection.up_to_date {
return Err(
"Синхронизация не завершена; запуск остановлен. Проверьте конфликты файлов.".into(),
);
}
let game_dir = directory.join("game");
let client = mojang::http_client().map_err(|e| e.to_string())?;
let runtime_client = runtime::http_client().map_err(|e| e.to_string())?;
let java = java::ensure_java(
&runtime_client,
&game_dir.join("runtime"),
manifest.minecraft.java_major,
&progress("java"),
)
.map_err(|e| e.to_string())?;
let merged = resolve_merged_version(
&client,
manifest,
Path::new(&java.executable),
&game_dir,
&game_dir.join("cache"),
&progress("neoforge"),
)?;
let libraries_client = mojang::library_http_client().map_err(|e| e.to_string())?;
mojang::ensure_client_jar(
&client,
&game_dir,
&merged.client_jar_version_id,
&merged.client,
)
.map_err(|e| e.to_string())?;
mojang::ensure_libraries(
&libraries_client,
&game_dir,
&merged.libraries,
&progress("libraries"),
)
.map_err(|e| e.to_string())?;
let index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index)
.map_err(|e| e.to_string())?;
mojang::ensure_assets(&client, &game_dir, &index, &progress("assets"))
.map_err(|e| e.to_string())?;
Ok((merged, java, inspection))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PreparationResult {
inspection: profile::ProfileInspection,
metadata: ProfileMetadata,
onboarding: Option<shacraft_account::LinkStart>,
}
/// Full repair, using one snapshot and one writer lock for mods AND the game.
#[tauri::command]
pub(crate) async fn ensure_game_installed(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
) -> Result<(), String> {
let game_dir = data_dir(&app)?.join("game");
let runtime_root = game_dir.join("runtime");
let cache_dir = game_dir.join("cache");
) -> Result<PreparationResult, String> {
let directory = data_dir(&app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, &state)?;
let permit = state.installation.acquire("Installation")?;
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let client = mojang::http_client().map_err(|error| error.to_string())?;
let runtime_client = runtime::http_client().map_err(|error| error.to_string())?;
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
let stage_progress = |stage: &'static str| -> mojang::ProgressCallback {
let app = app.clone();
Arc::new(move |current, total| {
let _ = app.emit(
"game-install-progress",
InstallProgress {
stage,
current_bytes: current,
total_bytes: total,
},
);
})
};
let java_install = java::ensure_java(
&runtime_client,
&runtime_root,
manifest.minecraft.java_major,
&stage_progress("java"),
)
.map_err(|error| error.to_string())?;
let merged = resolve_merged_version(
&client,
&manifest,
Path::new(&java_install.executable),
&game_dir,
&cache_dir,
&stage_progress("neoforge"),
)?;
// NeoForge may leave vanilla runtime libraries (including LWJGL) absent.
// Verify the full merged set, using the loader Maven only for libraries.
let library_client = mojang::library_http_client().map_err(|error| error.to_string())?;
mojang::ensure_client_jar(
&client,
&game_dir,
&merged.client_jar_version_id,
&merged.client,
)
.map_err(|error| error.to_string())?;
mojang::ensure_libraries(
&library_client,
&game_dir,
&merged.libraries,
&stage_progress("libraries"),
)
.map_err(|error| error.to_string())?;
let asset_index = mojang::ensure_asset_index(&client, &game_dir, &merged.asset_index)
.map_err(|error| error.to_string())?;
mojang::ensure_assets(&client, &game_dir, &asset_index, &stage_progress("assets"))
.map_err(|error| error.to_string())?;
Ok(())
let _lock = InstallationLock::acquire(&directory)?;
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
let (_, _, inspection) = prepare(&directory, &snapshot, &|stage| progress(&app, stage))?;
Ok(PreparationResult {
inspection,
metadata: (&snapshot).into(),
onboarding: None,
})
})
.await
.map_err(|error| format!("Install task failed: {error}"))?
.map_err(|e| e.to_string())?
}
#[derive(Clone, Serialize)]
@@ -153,90 +176,143 @@ pub(crate) struct GameExited {
exit_code: Option<i32>,
}
/// Launches `profile_id` with the verified ShaCraft account's linked nickname.
/// Local legacy nickname/account-mode preferences cannot override the link.
/// Spawns the game detached; watches it on a
/// background thread only to emit `game-exited` when it eventually closes.
async fn play(
app: AppHandle,
state: &LauncherOperations,
profile_id: String,
onboarding_name: Option<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]
pub(crate) async fn launch_game(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
) -> Result<(), String> {
let game_dir = data_dir(&app)?.join("game");
let data_dir = data_dir(&app)?;
let permit = state.installation.acquire("Installation")?;
let game_permit = state.game.acquire("Игра")?;
let account_operation = state.shacraft_account.clone();
) -> Result<PreparationResult, String> {
play(app, &state, profile_id, None).await
}
#[tauri::command]
pub(crate) async fn launch_onboarding(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
nickname: String,
) -> Result<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> {
let _permit = permit;
let client = mojang::http_client().map_err(|error| error.to_string())?;
let runtime_client = runtime::http_client().map_err(|error| error.to_string())?;
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
let profile_dir = data_dir.join("profiles").join(&manifest.id);
let settings = settings::load(&data_dir).map_err(|error| error.to_string())?;
#[cfg(test)]
mod tests {
use super::*;
// Everything here should already be installed by `ensure_game_installed`,
// so these are expected to hit their fast paths; no progress to show.
let no_progress: mojang::ProgressCallback = Arc::new(|_, _| {});
let java_install = java::ensure_java(
&runtime_client,
&game_dir.join("runtime"),
manifest.minecraft.java_major,
&no_progress,
)
.map_err(|error| error.to_string())?;
let merged = resolve_merged_version(
&client,
&manifest,
Path::new(&java_install.executable),
&game_dir,
&game_dir.join("cache"),
&no_progress,
)?;
let timestamp = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let log_dir = data_dir.join("logs");
std::fs::create_dir_all(&log_dir).map_err(|error| error.to_string())?;
let log_path = log_dir.join(format!("{profile_id}-{timestamp}.log"));
// 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,
/// Real provider downloads and installer execution; never logs in or joins
/// a server. Artifacts stay in a fresh temporary directory for diagnosis.
#[test]
#[ignore = "downloads the real pack/game and executes the official installer; needs network and Java 21"]
fn live_cold_install_and_corruption_repair() {
let directory = std::env::temp_dir().canonicalize().unwrap().join(format!(
"shacraft-cold-install-{}",
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
assert!(!directory.exists());
eprintln!("Isolated installation: {}", directory.display());
let _lock = InstallationLock::acquire(&directory).unwrap();
let snapshot = remote::fetch_snapshot("aeronautics").unwrap();
let callback = |stage| -> mojang::ProgressCallback {
eprintln!("Stage: {stage}");
Arc::new(|_, _| {})
};
let mut child = launch::launch(&request).map_err(|error| error.to_string())?;
let watch_app = app.clone();
let watch_profile_id = profile_id.clone();
std::thread::spawn(move || {
let game_permit = game_permit;
let exit_code = child.wait().ok().and_then(|status| status.code());
drop(game_permit);
let _ = watch_app.emit(
"game-exited",
GameExited {
profile_id: watch_profile_id,
exit_code,
},
);
});
Ok(())
})
.await
.map_err(|error| format!("Launch task failed: {error}"))?
let (_, java, inspection) = prepare(&directory, &snapshot, &callback).unwrap();
assert!(inspection.up_to_date);
assert_eq!(java.major, snapshot.manifest.minecraft.java_major);
let game = directory.join("game");
let version = &snapshot.manifest.minecraft.loader.version;
let json = neoforge::installed_version_json_path(&game, version);
let jar = game.join(format!(
"libraries/net/neoforged/neoforge/{version}/neoforge-{version}-client.jar"
));
let original_json = std::fs::read(&json).unwrap();
std::fs::write(&json, b"nonempty corrupted version JSON").unwrap();
std::fs::write(&jar, b"nonempty corrupted patched JAR").unwrap();
let (_, _, repaired) = prepare(&directory, &snapshot, &callback).unwrap();
assert!(repaired.up_to_date);
assert_eq!(std::fs::read(&json).unwrap(), original_json);
assert!(std::fs::metadata(&jar).unwrap().len() > 1024);
// A third preparation verifies the receipt and all downloads again.
assert!(
prepare(&directory, &snapshot, &callback)
.unwrap()
.2
.up_to_date
);
eprintln!(
"Cold install, corrupt JSON/JAR repair and healthy recheck passed: {}",
directory.display()
);
}
}
+11 -6
View File
@@ -1,6 +1,6 @@
use super::data_dir;
use crate::settings;
use tauri::AppHandle;
use crate::{operations::LauncherOperations, settings};
use tauri::{AppHandle, State};
#[tauri::command]
pub(crate) async fn load_settings(app: AppHandle) -> Result<settings::LauncherSettings, String> {
@@ -14,11 +14,16 @@ pub(crate) async fn load_settings(app: AppHandle) -> Result<settings::LauncherSe
#[tauri::command]
pub(crate) async fn save_settings(
app: AppHandle,
state: State<'_, LauncherOperations>,
settings: settings::LauncherSettings,
) -> Result<settings::LauncherSettings, String> {
let data_dir = data_dir(&app)?;
tauri::async_runtime::spawn_blocking(move || settings::save(&data_dir, settings))
.await
.map_err(|error| format!("Settings task failed: {error}"))?
.map_err(|error| error.to_string())
let lifecycle = crate::update_guard::begin_operation(&data_dir, &state)?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
settings::save(&data_dir, settings)
})
.await
.map_err(|error| format!("Settings task failed: {error}"))?
.map_err(|error| error.to_string())
}
+102 -15
View File
@@ -1,47 +1,134 @@
use super::data_dir;
use crate::{operations::LauncherOperations, profile, remote};
use crate::{installation_lock::InstallationLock, operations::LauncherOperations, profile, remote};
use serde::Serialize;
use tauri::{AppHandle, State};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProfileMetadata {
pub snapshot: String,
pub minecraft_version: String,
pub loader_kind: String,
pub loader_version: String,
pub java_major: u8,
}
impl From<&remote::VerifiedSnapshot> for ProfileMetadata {
fn from(snapshot: &remote::VerifiedSnapshot) -> Self {
let game = &snapshot.manifest.minecraft;
Self {
snapshot: snapshot.digest.clone(),
minecraft_version: game.version.clone(),
loader_kind: game.loader.kind.clone(),
loader_version: game.loader.version.clone(),
java_major: game.java_major,
}
}
}
#[tauri::command]
pub(crate) async fn profile_metadata(profile_id: String) -> Result<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]
pub(crate) async fn get_server_status(profile_id: String) -> Result<remote::ServerStatus, String> {
tauri::async_runtime::spawn_blocking(move || {
remote::fetch_server_status(&profile_id).map_err(|error| error.to_string())
remote::fetch_server_status(&profile_id).map_err(|e| e.to_string())
})
.await
.map_err(|error| format!("Server-status task failed: {error}"))?
.map_err(|e| e.to_string())?
}
/// Loads and validates the published ShaCraft manifest before inspecting a profile.
#[tauri::command]
pub(crate) async fn inspect_remote_profile(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
) -> Result<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 || {
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
profile::inspect(&data_dir.join("profiles").join(&manifest.id), &manifest)
.map_err(|error| error.to_string())
let _lifecycle = lifecycle;
// Inspection must not report a partially applied journal as ready.
let _lock = InstallationLock::acquire(&directory)?;
let manifest = remote::fetch_manifest(&profile_id).map_err(|e| e.to_string())?;
profile::inspect(&directory.join("profiles").join(&manifest.id), &manifest)
.map_err(|e| e.to_string())
})
.await
.map_err(|error| format!("Profile inspection task failed: {error}"))?
.map_err(|e| e.to_string())?
}
/// Downloads missing or changed ShaCraft-managed files from the fixed v2 endpoint.
#[tauri::command]
pub(crate) async fn sync_remote_profile(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
) -> Result<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")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
profile::sync(&data_dir.join("profiles").join(&manifest.id), &manifest)
.map_err(|error| error.to_string())
let _lock = InstallationLock::acquire(&directory)?;
let snapshot = remote::fetch_snapshot(&profile_id).map_err(|e| e.to_string())?;
profile::sync_snapshot(
&directory.join("profiles").join(&snapshot.manifest.id),
&snapshot,
)
.map_err(|e| e.to_string())
})
.await
.map_err(|error| format!("Profile synchronization task failed: {error}"))?
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub(crate) async fn legacy_mods(
app: AppHandle,
state: State<'_, LauncherOperations>,
profile_id: String,
) -> Result<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())?
}
+38 -13
View File
@@ -1,6 +1,9 @@
//! ShaCraft sessions and verified account links are the launch identity source.
use super::data_dir;
use crate::{operations::LauncherOperations, shacraft_account};
use crate::{
operations::{LauncherOperations, Operation},
session, shacraft_account,
};
use std::path::Path;
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,
) -> Result<T, String> {
let directory = data_dir(app)?;
let lifecycle = crate::update_guard::begin_operation(&directory, operations)?;
let permit = operations
.shacraft_account
.acquire("ShaCraft account operation")?;
tauri::async_runtime::spawn_blocking(move || {
let _lifecycle = lifecycle;
let _permit = permit;
work(&directory).map_err(|error| error.to_string())
})
@@ -72,18 +77,6 @@ pub(crate) async fn shacraft_start_link(
.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]
pub(crate) async fn shacraft_link_status(
app: AppHandle,
@@ -95,3 +88,35 @@ pub(crate) async fn shacraft_link_status(
})
.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
View File
@@ -1,203 +1,158 @@
//! No updater command accepts a URL, key, target, version, executable or arguments.
use super::data_dir;
use crate::{
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]
pub(crate) fn get_launcher_update_status(
app: AppHandle,
updater: State<'_, LauncherUpdater>,
) -> Result<UpdateStatus, String> {
updater.status(&app)
fn pending(app: &AppHandle, state: &UpdaterState) -> Result<Option<UpdateStatus>, String> {
let operations = app.state::<LauncherOperations>();
if let Err(reason) = operations.ensure_writable() {
return Ok(Some(state.recovery(app, reason)));
}
let directory = data_dir(app)?;
match update_guard::pending_reason(&directory, env!("CARGO_PKG_VERSION")) {
Ok(Some(reason)) | Err(reason) => {
// Recovery discovered after startup is just as permanent for this
// process. External marker deletion cannot resume native writes.
operations.latch_recovery(reason.clone());
Ok(Some(state.recovery(app, reason)))
}
Ok(None) => Ok(None),
}
}
fn contain_worker_panic<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]
pub(crate) async fn check_launcher_update(
pub(crate) fn updater_status(
app: AppHandle,
updater: State<'_, LauncherUpdater>,
state: State<'_, UpdaterState>,
) -> Result<UpdateStatus, String> {
let status = updater.status(&app)?;
if !status.supported || status.stage == Stage::Ready {
if state.critical() {
return Ok(state.status());
}
Ok(pending(&app, &state)?.unwrap_or_else(|| state.status()))
}
#[tauri::command]
pub(crate) async fn updater_check(
app: AppHandle,
state: State<'_, UpdaterState>,
) -> Result<UpdateStatus, String> {
let permit = state.acquire()?;
if let Some(status) = pending(&app, &state)? {
return Ok(status);
}
let permit = updater
.operation
.acquire("Проверка или установка обновления")
.map_err(|_| "Проверка или установка обновления уже выполняется.".to_string())?;
let updater = updater.inner().clone();
updater.set_stage(Stage::Checking)?;
// Detached task owns the permit: cancelling an IPC caller cannot release
// the operation while its native HTTP request is still in flight.
tauri::async_runtime::spawn(async move {
let state = state.inner().clone();
let worker_app = app.clone();
let worker_state = state.clone();
let worker = tauri::async_runtime::spawn_blocking(move || {
let _permit = permit;
let result = async {
let key = updater::public_key(&app)?;
updater::check_candidate(
updater::trusted_builder(&app)?,
&key,
&app.package_info().version.to_string(),
updater::installation_kind(&app),
)
.await
}
.await;
{
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())?
// Publish failure from the worker even if its IPC caller disappeared.
contain_worker_panic(
|| updater::check(&worker_app, &worker_state),
"Проверка обновления прервалась. Повторите проверку.",
)
.unwrap_or_else(|error| report_failure(&worker_app, &worker_state, error))
});
Ok(worker.await.unwrap_or_else(|_| {
report_failure(
&app,
&state,
"Проверка обновления прервалась. Повторите проверку.".into(),
)
}))
}
#[tauri::command]
pub(crate) async fn install_launcher_update(
pub(crate) async fn updater_download_install(
app: AppHandle,
updater: State<'_, LauncherUpdater>,
operations: State<'_, LauncherOperations>,
) -> Result<(), String> {
if let Some(reason) = updater::unsupported_reason(&app) {
return Err(reason);
state: State<'_, UpdaterState>,
) -> Result<UpdateStatus, String> {
let permit = state.acquire()?;
if let Some(status) = pending(&app, &state)? {
return Ok(status);
}
let permit = updater
.operation
.acquire("Проверка или установка обновления")
.map_err(|_| "Проверка или установка обновления уже выполняется.".to_string())?;
let candidate = {
let state = updater
.state
.lock()
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
if state.stage == Stage::Ready {
return Err("Обновление уже установлено. Перезапустите лаунчер.".into());
}
state
.candidate
.clone()
.ok_or("Сначала проверьте наличие обновлений.")?
};
let destination = updater::installation_path(&app)?;
let mutation_permits = operations.acquire_update()
.map_err(|_| "Закройте Minecraft и дождитесь завершения установки или входа в аккаунт перед обновлением.".to_string())?;
let updater = updater.inner().clone();
updater.set_stage(Stage::Downloading)?;
tauri::async_runtime::spawn(async move {
let directory = data_dir(&app)?;
let state = state.inner().clone();
let worker_app = app.clone();
let worker_state = state.clone();
let worker = tauri::async_runtime::spawn_blocking(move || {
let _permit = permit;
let result = async {
let key = updater::public_key(&app)?;
let _ = app.emit(
"launcher-update-progress",
UpdateProgress {
stage: Stage::Downloading,
downloaded_bytes: 0,
total_bytes: None,
},
);
let bytes =
updater::download_verified(&candidate, &key, |downloaded_bytes, total_bytes| {
let _ = app.emit(
"launcher-update-progress",
UpdateProgress {
stage: Stage::Downloading,
downloaded_bytes,
total_bytes,
},
);
})
.await?;
let size = bytes.len() as u64;
updater.set_stage(Stage::Installing)?;
let _ = app.emit(
"launcher-update-progress",
UpdateProgress {
stage: Stage::Installing,
downloaded_bytes: size,
total_bytes: Some(size),
},
);
tauri::async_runtime::spawn_blocking(move || {
updater::install_verified(&candidate, &bytes, &key, destination.as_deref())
})
.await
.map_err(|_| "Установка обновления завершилась с ошибкой.".to_string())??;
Ok::<_, String>(size)
}
.await;
match result {
Ok(size) => {
let mut state = updater
.state
.lock()
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
state.stage = Stage::Ready;
state.restart_permits = Some(mutation_permits);
let _ = app.emit(
"launcher-update-progress",
UpdateProgress {
stage: Stage::Ready,
downloaded_bytes: size,
total_bytes: Some(size),
},
);
Ok(())
}
Err(error) => {
updater.set_stage(Stage::Available)?;
Err(error)
}
}
})
.await
.map_err(|_| "Установка обновления завершилась с ошибкой.".to_string())?
contain_worker_panic(
|| {
let operations = worker_app.state::<LauncherOperations>();
updater::download_install(&worker_app, &worker_state, &directory, &operations)
},
"Установка обновления прервалась; проверьте состояние перед повторной попыткой.",
)
.unwrap_or_else(|error| report_failure(&worker_app, &worker_state, error))
});
Ok(worker.await.unwrap_or_else(|_| {
report_failure(
&app,
&state,
"Установка обновления прервалась; проверьте состояние перед повторной попыткой.".into(),
)
}))
}
#[tauri::command]
pub(crate) fn restart_launcher_after_update(
pub(crate) fn updater_restart(
app: AppHandle,
updater: State<'_, LauncherUpdater>,
state: State<'_, UpdaterState>,
) -> Result<(), String> {
let _permit = updater
.operation
.acquire("Установка обновления")
.map_err(|_| "Установка обновления ещё выполняется.".to_string())?;
{
let state = updater
.state
.lock()
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
if state.stage != Stage::Ready || state.restart_permits.is_none() {
return Err("Сначала установите обновление лаунчера.".into());
}
}
#[cfg(target_os = "linux")]
if updater::installation_kind(&app) == updater::InstallationKind::Deb
|| crate::deb_updater::is_deleted_installed_binary()
{
crate::deb_updater::restart()?;
app.exit(0);
return Ok(());
let _permit = state.acquire()?;
if !state.ready() {
return Err("Нет завершённого обновления для перезапуска".into());
}
app.restart()
}
#[tauri::command]
pub(crate) fn updater_open_release_page() -> Result<(), String> {
updater::open_release_page()
}
#[cfg(test)]
mod tests {
use super::contain_worker_panic;
use crate::operations::Operation;
#[test]
fn panicking_worker_releases_owned_permit_and_returns_a_retryable_boundary_error() {
let operation = Operation::default();
let permit = operation.acquire("test updater").unwrap();
let result: Result<(), String> = contain_worker_panic(
move || {
let _permit = permit;
panic!("synthetic worker failure");
},
"worker interrupted",
);
assert_eq!(result, Err("worker interrupted".into()));
assert!(operation.acquire("retry").is_ok());
assert_eq!(contain_worker_panic(|| Ok(42), "unused"), Ok(42));
}
}
-555
View File
@@ -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);
}
}
-220
View File
@@ -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()));
}
}
+326
View File
@@ -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"),
}
}
}
}
+602
View File
@@ -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
View File
@@ -60,18 +60,28 @@ pub fn ensure_java(
required_major: u8,
on_progress: &ProgressCallback,
) -> Result<JavaInstallation, EnsureJavaError> {
if let Some(installation) = detect() {
if installation.major == required_major {
on_progress(1, 1);
return Ok(installation);
}
if let Some(installation) = find_matching(candidates(), required_major, check_candidate) {
on_progress(1, 1);
return Ok(installation);
}
let executable = runtime::ensure_runtime(client, runtime_root, required_major, on_progress)
.map_err(EnsureJavaError::Provisioning)?;
check_candidate(executable.clone())
.filter(|installation| installation.major == required_major)
.ok_or(EnsureJavaError::ProvisionedButUnrecognised(executable))
}
fn find_matching(
candidates: impl IntoIterator<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> {
let executable = if cfg!(target_os = "windows") {
"java.exe"
@@ -127,7 +137,48 @@ fn parse_major(version: &str) -> Option<u8> {
#[cfg(test)]
mod tests {
use super::{parse_major, parse_version};
use super::*;
#[test]
fn matching_path_runtime_is_not_hidden_by_wrong_java_home() {
let candidates = ["JAVA_HOME", "PATH"].map(PathBuf::from);
let found = find_matching(candidates, 21, |path| {
let major = if path == Path::new("JAVA_HOME") {
17
} else {
21
};
Some(JavaInstallation {
executable: path.display().to_string(),
major,
version: major.to_string(),
})
})
.unwrap();
assert_eq!(found.executable, "PATH");
}
#[test]
fn matching_home_remains_preferred_and_unusable_candidates_are_skipped() {
let found = find_matching(["broken", "home", "path"].map(PathBuf::from), 21, |path| {
assert_ne!(path, Path::new("path"), "must stop at matching JAVA_HOME");
(path != Path::new("broken")).then(|| JavaInstallation {
executable: path.display().to_string(),
major: 21,
version: "21".into(),
})
})
.unwrap();
assert_eq!(found.executable, "home");
assert!(find_matching([PathBuf::from("newer")], 21, |path| Some(
JavaInstallation {
executable: path.display().to_string(),
major: 25,
version: "25".into()
}
))
.is_none());
}
#[test]
fn parses_modern_java_version() {
+23 -90
View File
@@ -1,10 +1,11 @@
//! Builds and spawns the real `java` invocation for a merged launch
//! profile. The `${auth_*}` placeholders use only the identity bound to the
//! server-issued admission ticket. Its one-use proof stays out of arguments
//! and files; only the Java child's environment receives it.
//! profile. The `${auth_*}` placeholders are filled from a `PlayerIdentity`,
//! which is either a real Microsoft-authenticated session (`msa::LoginResult`)
//! 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::session::PlayerIdentity;
use sha2::{Digest, Sha256};
use std::{
collections::{HashMap, HashSet},
@@ -43,9 +44,11 @@ pub struct LaunchRequest<'a> {
/// the shared `game_dir`.
pub profile_dir: &'a Path,
pub merged: &'a MergedVersion,
pub admission: &'a Admission,
pub identity: &'a PlayerIdentity,
pub memory_mb: u16,
pub log_path: &'a Path,
/// Short-lived onboarding grant; never persisted or placed in command-line arguments.
pub onboarding_token: Option<&'a str>,
}
fn classpath_separator() -> &'static str {
@@ -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
/// thread and emits an event).
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)?;
let natives_dir = mojang::natives_directory(request.game_dir, &request.merged.id);
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 mut vars: HashMap<&str, String> = HashMap::new();
let identity = request.admission.identity();
vars.insert("auth_player_name", identity.name().to_string());
vars.insert("auth_player_name", request.identity.name().to_string());
// NeoForge's inherited JVM profile uses `${version_name}.jar` in
// `-DignoreList`. The actual client jar belongs to the vanilla parent
// (`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("assets_root", assets_root.display().to_string());
vars.insert("assets_index_name", request.merged.asset_index.id.clone());
vars.insert("auth_uuid", identity.uuid());
vars.insert("auth_access_token", identity.access_token().to_string());
vars.insert("auth_uuid", request.identity.uuid());
vars.insert(
"auth_access_token",
request.identity.access_token().to_string(),
);
vars.insert("clientid", launcher_client_id(request.game_dir)?);
vars.insert("auth_xuid", identity.xuid().to_string());
vars.insert("user_type", identity.user_type().to_string());
vars.insert("auth_xuid", request.identity.xuid().to_string());
vars.insert("user_type", request.identity.user_type().to_string());
vars.insert("version_type", "ShaCraft Launcher".to_string());
vars.insert("natives_directory", natives_dir.display().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 mut command = Command::new(request.java_executable);
request.admission.configure_child(&mut command);
let memory_argument = format!("-Xmx{}M", request.memory_mb);
if cfg!(windows) {
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 {
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", "135.106.154.86:25568"]);
}
command.current_dir(request.profile_dir);
// Do not inherit a stale grant from the launcher process environment.
command.env_remove("SHACRAFT_ONBOARDING_TOKEN");
if let Some(token) = request.onboarding_token {
command.env("SHACRAFT_ONBOARDING_TOKEN", token);
}
command.stdin(Stdio::null());
let log_file = fs::File::create(request.log_path)?;
command.stdout(Stdio::from(log_file.try_clone()?));
command.stderr(Stdio::from(log_file));
Ok(command)
Ok(command.spawn()?)
}
#[cfg(test)]
mod tests {
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", "135.106.154.86: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]
fn generates_rfc4122_version_4_uuids() {
let id = random_uuid_v4();
+39 -27
View File
@@ -1,8 +1,9 @@
mod admission;
#[cfg(target_os = "linux")]
mod deb_updater;
mod download;
mod fabric;
mod update_guard;
mod updater;
use tauri::Manager;
mod installation_lock;
mod inventory;
mod java;
mod launch;
mod manifest;
@@ -17,37 +18,49 @@ mod settings;
mod shacraft_account;
mod storage;
mod trusted_http;
mod updater;
mod commands;
mod operations;
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()
.manage(operations::LauncherOperations::default())
.manage(updater::LauncherUpdater::default())
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(updater::UpdaterState::default())
.plugin(
tauri_plugin_updater::Builder::new()
.pubkey(updater::configured_key().unwrap_or(""))
.build(),
)
.setup(|app| {
let directory = app.path().app_data_dir()?;
let instance =
update_guard::InstanceGuard::acquire(&directory, env!("CARGO_PKG_VERSION"))
.map_err(std::io::Error::other)?;
if let Some(reason) = instance.recovery_reason() {
app.state::<operations::LauncherOperations>()
.latch_recovery(reason);
}
app.manage(instance);
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// Do not interrupt the short package replacement step. A
// download can safely be abandoned before any file changes.
if let Some(updater) = window.try_state::<updater::LauncherUpdater>() {
if updater
.state
.lock()
.is_ok_and(|state| state.stage == updater::Stage::Installing)
{
api.prevent_close();
}
if window
.app_handle()
.state::<operations::LauncherOperations>()
.lifecycle
.is_updating()
{
api.prevent_close();
}
}
})
.invoke_handler(tauri::generate_handler![
commands::updater::updater_status,
commands::updater::updater_check,
commands::updater::updater_download_install,
commands::updater::updater_restart,
commands::updater::updater_open_release_page,
commands::host::native_host,
commands::host::detect_java,
commands::host::microsoft_login_available,
@@ -55,23 +68,22 @@ pub fn run() {
commands::profiles::inspect_remote_profile,
commands::profiles::sync_remote_profile,
commands::profiles::get_server_status,
commands::profiles::profile_metadata,
commands::profiles::legacy_mods,
commands::profiles::backup_legacy_mods,
commands::preferences::load_settings,
commands::preferences::save_settings,
commands::shacraft::shacraft_authenticate,
commands::shacraft::get_shacraft_account,
commands::shacraft::shacraft_logout,
commands::shacraft::shacraft_start_link,
commands::shacraft::shacraft_claim_nickname,
commands::shacraft::shacraft_link_status,
commands::account::start_microsoft_login,
commands::account::get_account,
commands::account::logout,
commands::game::ensure_game_installed,
commands::game::launch_game,
commands::updater::get_launcher_update_status,
commands::updater::check_launcher_update,
commands::updater::install_launcher_update,
commands::updater::restart_launcher_after_update
commands::game::launch_onboarding
])
.run(tauri::generate_context!())
.expect("error while running ShaCraft Launcher");
+1 -1
View File
@@ -91,7 +91,7 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
));
}
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)
{
return Err(ManifestError::Invalid(
+1 -2
View File
@@ -58,7 +58,6 @@ pub fn library_http_client() -> Result<Client, reqwest::Error> {
MOJANG_HOSTS[2],
MOJANG_HOSTS[3],
crate::neoforge::NEOFORGE_HOST,
crate::fabric::MAVEN_HOST,
],
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
/// NeoForge installer, never from the ShaCraft manifest.
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)]
+58 -93
View File
@@ -25,6 +25,9 @@
//! arguments already present on the merged profile) and is intentionally
//! never added to our own classpath.
#[path = "neoforge_repair.rs"]
mod repair;
use crate::download::{self, Checksum, DownloadError, ProgressCallback};
use crate::mojang::VersionJson;
use reqwest::blocking::Client;
@@ -56,6 +59,7 @@ pub enum NeoForgeError {
Download(DownloadError),
Io(io::Error),
InvalidJson(serde_json::Error),
InvalidInstallation(String),
InstallerFailed {
exit_code: Option<i32>,
output_tail: String,
@@ -76,6 +80,9 @@ impl fmt::Display for NeoForgeError {
Self::Download(error) => write!(formatter, "{error}"),
Self::Io(error) => write!(formatter, "I/O error: {error}"),
Self::InvalidJson(error) => write!(formatter, "invalid NeoForge version JSON: {error}"),
Self::InvalidInstallation(message) => {
write!(formatter, "invalid NeoForge installation: {message}")
}
Self::InstallerFailed {
exit_code,
output_tail,
@@ -166,25 +173,8 @@ pub fn installed_version_json_path(game_dir: &Path, loader_version: &str) -> Pat
.join(format!("neoforge-{loader_version}.json"))
}
fn patched_client_path(game_dir: &Path, loader_version: &str) -> PathBuf {
game_dir
.join("libraries/net/neoforged/neoforge")
.join(loader_version)
.join(format!("neoforge-{loader_version}-client.jar"))
}
fn is_nonempty_file(path: &Path) -> bool {
path.metadata()
.is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0)
}
fn installation_complete(game_dir: &Path, loader_version: &str) -> bool {
is_nonempty_file(&installed_version_json_path(game_dir, loader_version))
&& is_nonempty_file(&patched_client_path(game_dir, loader_version))
}
/// The installer jar bundles its own `install_profile.json`, which lists
/// exactly which libraries it will download and which processors it will
/// which libraries it may download and which processors it may
/// run to patch the client — the same manifest the installer itself reads.
/// Reading it upfront gives a real, version-agnostic total for progress
/// reporting instead of a guessed constant.
@@ -316,66 +306,51 @@ fn run_installer_with_progress(
Ok((status.code(), tail))
}
/// Ensures NeoForge `loader_version` is installed into the shared
/// `game_dir` (vanilla libraries/version must already be there so the
/// installer can reuse them). No-op if already installed. Runs the
/// installer headlessly with `java_executable`; its own network calls go
/// straight to `maven.neoforged.net`/Mojang, outside our control, which is
/// an accepted trust delegation to NeoForge's official tooling once the
/// installer binary itself is SHA-256 verified. `on_progress` reports real
/// progress (installer-confirmed library downloads plus patch-processor
/// steps, read from the installer's own `install_profile.json`) while it
/// runs; it fires once with `(1, 1)` when already installed.
/// Verifies a generated installation against its provenance receipt. Legacy
/// installations and corrupt outputs are rebuilt by the verified official
/// installer in an empty staging directory. The caller must ensure vanilla's
/// client JAR first; the staged copy is checked against `vanilla` again before
/// any processor runs. No existing generated artifacts are adopted as trusted.
pub fn ensure_client_installed(
client: &Client,
java_executable: &Path,
game_dir: &Path,
cache_dir: &Path,
loader_version: &str,
vanilla: &VersionJson,
on_progress: &ProgressCallback,
) -> Result<VersionJson, NeoForgeError> {
let version_json_path = installed_version_json_path(game_dir, loader_version);
if !installation_complete(game_dir, loader_version) {
ensure_launcher_profiles_stub(game_dir)?;
let installer_path = ensure_installer(client, cache_dir, loader_version)?;
// A leftover version JSON makes some installer versions treat the
// profile as already installed even when the patched client was
// deleted or quarantined. Remove only that generated marker so the
// official installer is forced to rebuild the incomplete profile.
match fs::remove_file(&version_json_path) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(NeoForgeError::Io(error)),
}
let (total_libraries, total_processors) =
read_install_profile_counts(&installer_path).unwrap_or((0, 0));
let total = (total_libraries + total_processors).max(1);
on_progress(0, total);
let (exit_code, tail) = run_installer_with_progress(
java_executable,
&installer_path,
game_dir,
cache_dir,
total_libraries,
total,
on_progress,
)?;
if !installation_complete(game_dir, loader_version) {
return Err(NeoForgeError::InstallerFailed {
exit_code,
output_tail: tail,
});
}
on_progress(total, total);
} else {
let installer_path = ensure_installer(client, cache_dir, loader_version)?;
let mut rebuilt = false;
let version = repair::ensure(
&installer_path,
game_dir,
cache_dir,
loader_version,
vanilla,
|stage| {
rebuilt = true;
let (total_libraries, total_processors) =
read_install_profile_counts(&installer_path).unwrap_or((0, 0));
let total = (total_libraries + total_processors).max(1);
on_progress(0, total);
run_installer_with_progress(
java_executable,
&installer_path,
stage,
cache_dir,
total_libraries,
total,
on_progress,
)?;
on_progress(total, total);
Ok(())
},
)?;
if !rebuilt {
on_progress(1, 1);
}
let bytes = fs::read(&version_json_path)?;
serde_json::from_slice(&bytes).map_err(NeoForgeError::InvalidJson)
Ok(version)
}
#[cfg(test)]
@@ -412,25 +387,6 @@ mod tests {
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn incomplete_install_is_not_accepted() {
let dir = std::env::temp_dir().join(format!(
"shacraft-neoforge-completeness-test-{}",
std::process::id()
));
let version = "21.1.248";
let json = installed_version_json_path(&dir, version);
fs::create_dir_all(json.parent().unwrap()).unwrap();
fs::write(&json, b"{}").unwrap();
assert!(!installation_complete(&dir, version));
let client = patched_client_path(&dir, version);
fs::create_dir_all(client.parent().unwrap()).unwrap();
fs::write(&client, b"patched").unwrap();
assert!(installation_complete(&dir, version));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn observe_installer_line_counts_downloads_and_processor_headers() {
let downloads_done = AtomicU64::new(0);
@@ -492,8 +448,7 @@ mod tests {
/// Full live pipeline: provisions a real Java 21 (runtime.rs) if none
/// is already usable, then runs the real NeoForge 21.1.248 installer
/// into an empty game dir (it fetches and patches vanilla 1.21.1
/// itself — confirmed manually, no pre-seeding needed) and checks the
/// into a staging game dir with a verified vanilla 1.21.1 input and checks the
/// installed profile merges into a launch-shaped spec together with a
/// separately-fetched vanilla version JSON (mojang.rs), exactly as
/// `lib.rs`'s `ensure_game_installed` command will do it. Not run by
@@ -504,8 +459,10 @@ mod tests {
use crate::{java, mojang};
let client = Client::builder().build().unwrap();
let root =
std::env::temp_dir().join(format!("shacraft-neoforge-pipeline-{}", std::process::id()));
let root = std::env::temp_dir()
.canonicalize()
.unwrap()
.join(format!("shacraft-neoforge-pipeline-{}", std::process::id()));
let game_dir = root.join("game");
let cache_dir = root.join("cache");
fs::create_dir_all(&cache_dir).unwrap();
@@ -518,8 +475,14 @@ mod tests {
let java_install =
java::ensure_java(&client, &root.join("runtime"), 21, &no_progress).unwrap();
// The installer fetches and patches vanilla itself; we don't
// pre-download it. It only needs a Java runtime and an empty dir.
// Verify vanilla before the installer is allowed to use it.
mojang::ensure_client_jar(
&client,
&game_dir,
&vanilla.id,
&vanilla.downloads.as_ref().unwrap().client,
)
.unwrap();
let progress_calls: Arc<Mutex<Vec<(u64, u64)>>> = Arc::new(Mutex::new(Vec::new()));
let progress: ProgressCallback = {
let progress_calls = Arc::clone(&progress_calls);
@@ -531,6 +494,7 @@ mod tests {
&game_dir,
&cache_dir,
"21.1.248",
&vanilla,
&progress,
)
.unwrap();
@@ -577,6 +541,7 @@ mod tests {
&game_dir,
&cache_dir,
"21.1.248",
&vanilla,
&no_progress,
)
.unwrap();
+765
View File
@@ -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
View File
@@ -3,46 +3,17 @@
//! Acquire before scheduling the worker and move the permit into it. Dropping
//! the caller's future cannot unlock an operation that is still running.
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
Arc, Mutex,
};
#[derive(Clone, Default)]
#[derive(Default)]
pub(crate) struct LauncherOperations {
pub lifecycle: Lifecycle,
recovery: Mutex<Option<String>>,
pub installation: Operation,
pub 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)]
@@ -67,7 +38,7 @@ impl Drop for Permit {
#[cfg(test)]
mod tests {
use super::{LauncherOperations, Operation};
use super::Operation;
#[test]
fn rejects_overlap_and_releases_on_worker_error() {
@@ -80,43 +51,63 @@ mod tests {
assert!(worker().is_err());
assert!(operation.acquire("Installation").is_ok());
}
}
#[test]
fn running_game_blocks_update_and_partial_locks_are_released() {
let operations = LauncherOperations::default();
let game = operations.game.acquire("game").unwrap();
assert!(operations.acquire_update().is_err());
assert!(operations.installation.acquire("install").is_ok());
assert!(operations.account.acquire("account").is_ok());
assert!(operations
.shacraft_account
.acquire("ShaCraft account")
.is_ok());
drop(game);
assert!(operations.acquire_update().is_ok());
// Owned, Send permits are held by the worker, not the awaiting IPC future.
// Readers represent all native operations which can persist or launch; the
// sole writer represents launcher replacement, including its download stage.
#[derive(Default)]
pub(crate) struct Lifecycle(Arc<AtomicUsize>);
const EXCLUSIVE: usize = usize::MAX;
impl Lifecycle {
pub fn shared(&self) -> Result<SharedPermit, String> {
self.0
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| {
if n < EXCLUSIVE - 1 {
Some(n + 1)
} else {
None
}
})
.map_err(|_| "Лаунчер обновляется. Дождитесь завершения и перезапуска.".to_string())?;
Ok(SharedPermit(self.0.clone()))
}
#[test]
fn update_excludes_game_and_accounts_until_permit_drop() {
let operations = LauncherOperations::default();
let permit = operations.acquire_update().unwrap();
assert!(operations.installation.acquire("install").is_err());
assert!(operations.account.acquire("account").is_err());
assert!(operations
.shacraft_account
.acquire("ShaCraft account")
.is_err());
assert!(operations.game.acquire("game").is_err());
assert!(operations.acquire_update().is_err());
drop(permit);
assert!(operations.acquire_update().is_ok());
pub fn exclusive(&self) -> Result<ExclusivePermit, String> {
self.0
.compare_exchange(0, EXCLUSIVE, Ordering::AcqRel, Ordering::Acquire)
.map_err(|_| {
"Завершите операцию с игрой, настройками или аккаунтом и повторите обновление."
.to_string()
})?;
Ok(ExclusivePermit(self.0.clone()))
}
pub fn is_updating(&self) -> bool {
self.0.load(Ordering::Acquire) == EXCLUSIVE
}
}
pub(crate) struct SharedPermit(Arc<AtomicUsize>);
impl Drop for SharedPermit {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Release);
}
}
pub(crate) struct ExclusivePermit(Arc<AtomicUsize>);
impl Drop for ExclusivePermit {
fn drop(&mut self) {
self.0.store(0, Ordering::Release);
}
}
#[test]
fn account_operation_blocks_update_without_stranding_installation() {
let operations = LauncherOperations::default();
let _account = operations.shacraft_account.acquire("account").unwrap();
assert!(operations.acquire_update().is_err());
assert!(operations.installation.acquire("install").is_ok());
impl LauncherOperations {
/// Startup recovery is latched for this process. Removing a marker in a
/// running application is never authority to resume writes or launch.
pub fn latch_recovery(&self, reason: String) {
*self.recovery.lock().unwrap() = Some(reason);
}
pub fn ensure_writable(&self) -> Result<(), String> {
match self.recovery.lock().unwrap().as_ref() {
Some(reason) => Err(reason.clone()),
None => Ok(()),
}
}
}
+731 -111
View File
@@ -1,8 +1,12 @@
use crate::download::{self, Checksum, DownloadError};
use crate::inventory::{self, Change, Fingerprint, Inventory, OwnedFile, Store};
use crate::manifest::{is_allowed_download_url, FilePolicy, ManagedFile, Manifest};
use reqwest::{blocking::Client, redirect::Policy};
use serde::Serialize;
use serde::{Deserialize, Serialize};
#[cfg(test)]
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeSet, HashSet},
fmt, fs, io,
path::{Path, PathBuf},
time::Duration,
@@ -15,6 +19,10 @@ pub struct ProfileInspection {
pub managed_files: usize,
pub missing_files: usize,
pub mismatched_files: usize,
pub stale_files: usize,
pub conflicts: Vec<String>,
pub pending_update: bool,
pub legacy_files: usize,
pub up_to_date: bool,
}
@@ -25,6 +33,7 @@ pub struct SyncResult {
pub downloaded_files: usize,
pub reused_files: usize,
pub downloaded_bytes: u64,
pub removed_files: usize,
}
#[derive(Debug)]
@@ -33,54 +42,121 @@ pub enum ProfileError {
Network(reqwest::Error),
Download { path: String, source: DownloadError },
UnsafePath(PathBuf),
Conflict(Vec<String>),
}
impl fmt::Display for ProfileError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(formatter, "Cannot access profile: {error}"),
Self::Network(error) => write!(formatter, "Cannot download profile file: {error}"),
Self::Download { path, source } => {
write!(formatter, "Download failed for {path}: {source}")
}
Self::UnsafePath(path) => write!(
formatter,
"Profile path contains a symbolic link: {}",
path.display()
),
Self::Download { path, source } => write!(formatter, "Download failed for {path}: {source}"),
Self::UnsafePath(path) => write!(formatter, "Unsafe or protected profile path: {}", path.display()),
Self::Conflict(paths) => write!(formatter, "Файлы изменены или не принадлежат лаунчеру; сохранены без изменений: {}. Проверьте список пользовательских модов.", paths.join(", ")),
}
}
}
fn expected_fingerprint(file: &ManagedFile) -> Fingerprint {
Fingerprint {
size: file.size,
sha256: file.sha256.to_ascii_lowercase(),
}
}
#[cfg(test)]
fn manifest_snapshot(manifest: &Manifest) -> String {
// Internal identity of the already verified manifest's installation inputs.
// This fingerprint never substitutes for remote signature verification.
let mut hash = Sha256::new();
for value in [
&manifest.id,
&manifest.minecraft.version,
&manifest.minecraft.loader.kind,
&manifest.minecraft.loader.version,
] {
hash.update(value.as_bytes());
hash.update([0]);
}
hash.update([manifest.minecraft.java_major]);
for file in &manifest.files {
for value in [&file.path, &file.url, &file.sha256] {
hash.update(value.as_bytes());
hash.update([0]);
}
hash.update(file.size.to_le_bytes());
hash.update([if file.policy == FilePolicy::Managed {
1
} else {
2
}]);
}
format!("{:x}", hash.finalize())
}
fn current(root: &Path, relative: &str) -> Result<Option<Fingerprint>, ProfileError> {
inventory::fingerprint(&managed_target(root, relative)?).map_err(ProfileError::Io)
}
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 mismatched_files = 0;
let mut conflicts = Vec::new();
let paths: HashSet<_> = manifest
.files
.iter()
.map(|file| file.path.as_str())
.collect();
for expected in &manifest.files {
let path = managed_target(root, &expected.path)?;
if !path.try_exists().map_err(ProfileError::Io)? {
let actual = current(root, &expected.path)?;
if actual.is_none() {
missing_files += 1;
continue;
}
if matches!(expected.policy, FilePolicy::Seed) && path.is_file() {
if expected.policy == FilePolicy::Seed {
continue;
}
let checksum = Checksum::Sha256(expected.sha256.clone());
if !download::is_current(&path, Some(expected.size), &checksum).map_err(ProfileError::Io)? {
if actual.as_ref() != Some(&expected_fingerprint(expected)) {
mismatched_files += 1;
if owned.files.get(&expected.path).map(|f| &f.fingerprint) != actual.as_ref() {
conflicts.push(expected.path.clone());
}
}
}
let mut stale_files = 0;
for (path, previous) in &owned.files {
if paths.contains(path.as_str()) {
continue;
}
if let Some(actual) = current(root, path)? {
stale_files += 1;
if actual != previous.fingerprint {
conflicts.push(path.clone());
}
}
}
let legacy_files = legacy_mods(root, manifest, &owned)?.len();
Ok(ProfileInspection {
root: root.display().to_string(),
managed_files: manifest.files.len(),
missing_files,
mismatched_files,
up_to_date: missing_files == 0 && mismatched_files == 0,
stale_files,
pending_update,
legacy_files,
up_to_date: missing_files == 0
&& mismatched_files == 0
&& stale_files == 0
&& conflicts.is_empty()
&& !pending_update,
conflicts,
})
}
pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError> {
pub fn sync_snapshot(
root: &Path,
snapshot: &crate::remote::VerifiedSnapshot,
) -> Result<SyncResult, ProfileError> {
let client = Client::builder()
.connect_timeout(Duration::from_secs(15))
.timeout(Duration::from_secs(10 * 60))
@@ -95,64 +171,309 @@ pub fn sync(root: &Path, manifest: &Manifest) -> Result<SyncResult, ProfileError
}))
.build()
.map_err(ProfileError::Network)?;
let mut downloaded_files = 0;
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,
sync_with_snapshot(root, &snapshot.manifest, &snapshot.digest, |file, stage| {
download_managed_file(&client, file, stage)
})
}
/// Reject pre-existing links in the managed subtree before inspecting or
/// replacing files. A signed relative path must not follow a local link into
/// an unrelated directory. This is not a sandbox against a hostile local user
/// changing directories concurrently under the launcher's OS identity.
fn managed_target(root: &Path, relative: &str) -> Result<PathBuf, ProfileError> {
let mut path = root.to_path_buf();
if let Some(parent) = root.parent() {
reject_symlink(parent)?;
}
reject_symlink(&path)?;
for component in relative.split('/') {
path.push(component);
reject_symlink(&path)?;
}
Ok(path)
#[cfg(test)]
fn sync_with(
root: &Path,
manifest: &Manifest,
download: impl FnMut(&ManagedFile, &Path) -> Result<u64, ProfileError>,
) -> Result<SyncResult, ProfileError> {
sync_with_snapshot(root, manifest, &manifest_snapshot(manifest), download)
}
fn reject_symlink(path: &Path) -> Result<(), ProfileError> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
Err(ProfileError::UnsafePath(path.to_path_buf()))
fn sync_with_snapshot(
root: &Path,
manifest: &Manifest,
snapshot: &str,
mut download: impl FnMut(&ManagedFile, &Path) -> Result<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(
@@ -160,18 +481,17 @@ fn download_managed_file(
expected: &ManagedFile,
target: &Path,
) -> Result<u64, ProfileError> {
let checksum = Checksum::Sha256(expected.sha256.clone());
download::download_verified(
client,
&expected.url,
target,
Some(expected.size),
&checksum,
&Checksum::Sha256(expected.sha256.clone()),
|_, _| {},
)
.map_err(|error| ProfileError::Download {
.map_err(|source| ProfileError::Download {
path: expected.path.clone(),
source: error,
source,
})
}
@@ -179,45 +499,17 @@ fn download_managed_file(
mod tests {
use super::inspect;
use crate::manifest::{FilePolicy, Loader, ManagedFile, Manifest, Minecraft};
#[cfg(test)]
use sha2::{Digest, Sha256};
use std::{
fs, process,
time::{SystemTime, UNIX_EPOCH},
};
#[test]
#[ignore = "downloads the public signed admission mod into a temporary directory"]
fn live_admission_mod_is_restored_when_missing_or_corrupt() {
let mut manifest = crate::remote::fetch_manifest("aeronautics").unwrap();
manifest.files.retain(|file| {
file.path.starts_with("mods/shacraft-admission") && file.path.ends_with(".jar")
});
assert_eq!(
manifest.files.len(),
1,
"exactly one admission mod is required"
);
assert!(matches!(manifest.files[0].policy, FilePolicy::Managed));
let root = std::env::temp_dir().join(format!(
"shacraft-live-admission-{}-{}",
process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
assert_eq!(inspect(&root, &manifest).unwrap().missing_files, 1);
super::sync(&root, &manifest).unwrap();
assert!(inspect(&root, &manifest).unwrap().up_to_date);
let target = root.join(&manifest.files[0].path);
fs::write(&target, b"corrupt fixture").unwrap();
assert!(!inspect(&root, &manifest).unwrap().up_to_date);
super::sync(&root, &manifest).unwrap();
assert!(inspect(&root, &manifest).unwrap().up_to_date);
fs::remove_file(&target).unwrap();
super::sync(&root, &manifest).unwrap();
assert!(inspect(&root, &manifest).unwrap().up_to_date);
fs::remove_dir_all(root).unwrap();
fn trusted_temporary_root() -> std::path::PathBuf {
// Resolve only the OS-provided fixture anchor. The profile root, its
// parent and payload descendants retain their production link checks.
std::env::temp_dir().canonicalize().unwrap()
}
fn manifest(hash: String, size: u64) -> Manifest {
@@ -245,7 +537,7 @@ mod tests {
#[test]
fn reports_missing_and_matching_files() {
let root = std::env::temp_dir().join(format!(
let root = trusted_temporary_root().join(format!(
"shacraft-launcher-test-{}-{}",
process::id(),
SystemTime::now()
@@ -273,7 +565,7 @@ mod tests {
#[test]
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-{}-{}",
process::id(),
SystemTime::now()
@@ -291,7 +583,7 @@ mod tests {
#[test]
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-{}-{}",
process::id(),
SystemTime::now()
@@ -314,7 +606,7 @@ mod tests {
#[test]
fn refuses_linked_profile_directories() {
use std::os::unix::fs::symlink;
let root = std::env::temp_dir().join(format!(
let root = trusted_temporary_root().join(format!(
"shacraft-link-test-{}-{}",
process::id(),
SystemTime::now()
@@ -332,3 +624,331 @@ mod tests {
fs::remove_dir_all(root).unwrap();
}
}
#[cfg(test)]
mod update_tests {
use super::*;
use crate::manifest::{Loader, Minecraft};
use std::{
collections::BTreeMap,
sync::atomic::{AtomicU64, Ordering},
};
static NEXT: AtomicU64 = AtomicU64::new(0);
struct Fixture {
base: PathBuf,
root: PathBuf,
}
impl Fixture {
fn new() -> Self {
let base = std::env::temp_dir().join(format!(
"shacraft-update-{}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
let root = base.join("profiles/aeronautics");
fs::create_dir_all(&root).unwrap();
Self { base, root }
}
fn write(&self, path: &str, bytes: &[u8]) {
let target = self.root.join(path);
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(target, bytes).unwrap();
}
fn bytes(&self, path: &str) -> Vec<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
View File
@@ -4,6 +4,7 @@ use ed25519_dalek::{Signature, VerifyingKey};
use reqwest::header::ACCEPT_ENCODING;
use reqwest::{blocking::Client, redirect::Policy};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
fmt,
io::{self, Read},
@@ -13,8 +14,6 @@ use std::{
const AERONAUTICS_MANIFEST: &str =
"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 MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ=";
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> {
Ok(fetch_snapshot(profile_id)?.manifest)
}
pub fn fetch_snapshot(profile_id: &str) -> Result<VerifiedSnapshot, RemoteError> {
let url = match profile_id {
"aeronautics" => AERONAUTICS_MANIFEST,
"minigames" => MINIGAMES_MANIFEST,
_ => return Err(RemoteError::UnknownProfile),
};
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 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> {
@@ -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> {
let url = match profile_id {
"aeronautics" => AERONAUTICS_ONLINE,
"minigames" => MINIGAMES_ONLINE,
_ => return Err(RemoteError::UnknownProfile),
};
let client = Client::builder()
@@ -158,11 +164,20 @@ fn read_envelope(source: impl Read) -> Result<Vec<u8>, RemoteError> {
Ok(bytes)
}
#[cfg(test)]
fn verify_envelope(
source: &[u8],
profile_id: &str,
public_key: &VerifyingKey,
) -> 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 {
return Err(RemoteError::TooLarge);
}
@@ -182,12 +197,13 @@ fn verify_envelope(
public_key
.verify_strict(&payload, &signature)
.map_err(|_| RemoteError::InvalidSignature)?;
let digest = format!("{:x}", Sha256::digest(&payload));
let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?;
let manifest = manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)?;
if manifest.id != profile_id {
return Err(RemoteError::ProfileMismatch);
}
Ok(manifest)
Ok(VerifiedSnapshot { manifest, digest })
}
#[cfg(test)]
+144 -123
View File
@@ -6,12 +6,7 @@
use reqwest::blocking::{Client, Response};
use reqwest::redirect::Policy;
use serde::{Deserialize, Serialize};
use std::{
fmt, fs,
io::{self, Read},
path::Path,
time::Duration,
};
use std::{fmt, fs, io, path::Path, time::Duration};
const API_ORIGIN: &str = "https://shacraft.ru";
const SESSION_FILE: &str = "shacraft-session";
@@ -50,9 +45,21 @@ pub struct LoginResult {
#[derive(Clone, Deserialize, Serialize)]
pub struct LinkStart {
pub proof_version: u32,
pub server_id: String,
pub challenge_id: i64,
pub expires_in_seconds: u64,
pub registered_on_server: bool,
pub proof_code: String,
pub mc_username: String,
pub player_uuid: String,
}
#[derive(Deserialize)]
pub struct OnboardingGrant {
#[serde(flatten)]
pub challenge: LinkStart,
pub grant_token: String,
}
#[derive(Clone, Deserialize, Serialize)]
@@ -67,6 +74,7 @@ pub enum AccountError {
Api(String),
Io(io::Error),
InvalidSession,
NoLinkedNickname,
}
impl fmt::Display for AccountError {
@@ -76,6 +84,9 @@ impl fmt::Display for AccountError {
Self::Api(message) => formatter.write_str(message),
Self::Io(error) => write!(formatter, "Не удалось сохранить сессию: {error}"),
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() {
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> {
@@ -222,101 +318,56 @@ pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result<LinkStatus, Acc
response.json::<LinkStatus>().map_err(AccountError::Network)
}
const ADMISSION_ENDPOINT: &str = "/api/launcher/v2/admission/tickets";
/// Error responses at this boundary never echo arbitrary response bodies: a
/// misconfigured proxy/service must not copy credentials into UI diagnostics.
fn admission_error(status: reqwest::StatusCode) -> AccountError {
use reqwest::StatusCode;
match status {
StatusCode::UNAUTHORIZED => AccountError::InvalidSession,
StatusCode::FORBIDDEN => AccountError::Api(
"Нет разрешения на вход в Aeronautics. Проверьте привязку ника и доступ к серверу в аккаунте ShaCraft.".into()),
StatusCode::CONFLICT => AccountError::Api(
"Этот ник уже занят или зарезервирован. Если это ваш игровой ник, обратитесь в поддержку ShaCraft.".into()),
StatusCode::NOT_FOUND | StatusCode::SERVICE_UNAVAILABLE => AccountError::Api(
"Вход через ShaCraft Launcher пока не настроен на сервере. Повторите попытку позже.".into()),
StatusCode::TOO_MANY_REQUESTS => AccountError::Api(
"Слишком много запросов входа. Подождите немного и повторите попытку.".into()),
_ => AccountError::Api(format!("Не удалось получить разрешение ShaCraft: HTTP {status}")),
}
}
fn checked_admission_response(
data_dir: &Path,
response: Response,
) -> Result<Response, AccountError> {
if response.status().is_success() {
return Ok(response);
}
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
let _ = fs::remove_file(session_path(data_dir));
}
Err(admission_error(response.status()))
}
/// The server reserves a free nickname atomically for this account. Existing
/// player names remain reserved for administrator-assisted migration.
pub fn claim_nickname(data_dir: &Path, nickname: &str) -> Result<Account, AccountError> {
let token = load_session(data_dir)?;
let response = client()?
.post(format!("{API_ORIGIN}/api/launcher/v2/admission/nickname"))
.bearer_auth(token)
.json(&serde_json::json!({"server_id": "aoc", "mc_username": nickname}))
.send()
.map_err(AccountError::Network)?;
checked_admission_response(data_dir, response)?
.json()
.map_err(AccountError::Network)
}
/// Called only after installation, immediately before Java spawn. Nothing in
/// this response is exposed to the webview or persisted with account settings.
pub(crate) fn issue_admission(
data_dir: &Path,
server_id: &'static str,
) -> Result<crate::admission::Admission, AccountError> {
let token = load_session(data_dir)?;
let key = crate::admission::AdmissionKey::generate(server_id)
.map_err(|message| AccountError::Api(message.into()))?;
let response = client()?
.post(format!("{API_ORIGIN}{ADMISSION_ENDPOINT}"))
.bearer_auth(token)
.json(&key.request())
.send()
.map_err(AccountError::Network)?;
let response = checked_admission_response(data_dir, response)?;
// The expected object is under 256 bytes; bound the remote allocation and
// use a fixed parse error without response values or secret-bearing bodies.
let mut body = zeroize::Zeroizing::new(Vec::new());
response.take(4097).read_to_end(&mut body).map_err(|_| {
AccountError::Api(
"Не удалось прочитать разрешение на вход. Повторите попытку позже.".into(),
)
})?;
let invalid = || {
AccountError::Api(
"Сервер вернул некорректное разрешение на вход. Повторите попытку позже.".into(),
)
};
if body.len() > 4096 {
return Err(invalid());
}
let payload = serde_json::from_slice(&body).map_err(|_| invalid())?;
key.bind(payload)
.map_err(|message| AccountError::Api(message.into()))
pub fn aeronautics_nickname(data_dir: &Path) -> Result<String, AccountError> {
get_account(data_dir)?
.links
.into_iter()
.find(|link| link.server_id == "aoc")
.map(|link| link.mc_username)
.ok_or(AccountError::NoLinkedNickname)
}
#[cfg(test)]
mod tests {
use super::{
admission_error, issue_admission, load_session, save_session, session_path, AccountError,
};
use super::{load_session, save_session, session_path};
use std::{
fs, process,
time::{SystemTime, UNIX_EPOCH},
};
#[test]
fn challenge_requires_matching_server_exact_nickname_uuid_and_bounded_nonce() {
let valid = super::LinkStart {
proof_version: 1,
server_id: "aoc".into(),
challenge_id: 1,
expires_in_seconds: 600,
registered_on_server: false,
proof_code: "a".repeat(32),
mc_username: "ShaCraft_Test".into(),
player_uuid: crate::session::offline_uuid("ShaCraft_Test"),
};
assert!(super::validate_challenge(&valid, "aoc", "ShaCraft_Test").is_ok());
assert!(super::validate_challenge(&valid, "create", "ShaCraft_Test").is_err());
assert!(super::validate_challenge(&valid, "aoc", "shacraft_test").is_err());
let mut wrong = valid.clone();
wrong.player_uuid = crate::session::offline_uuid("shacraft_test");
assert!(super::validate_challenge(&wrong, "aoc", "ShaCraft_Test").is_err());
for ttl in [0, 601] {
wrong = valid.clone();
wrong.expires_in_seconds = ttl;
assert!(super::validate_challenge(&wrong, "aoc", "ShaCraft_Test").is_err());
}
wrong = valid.clone();
wrong.proof_version = 0;
assert!(super::validate_challenge(&wrong, "aoc", "ShaCraft_Test").is_err());
for code in ["a".repeat(31), "g".repeat(32), "a".repeat(33)] {
wrong = valid.clone();
wrong.proof_code = code;
assert!(super::validate_challenge(&wrong, "aoc", "ShaCraft_Test").is_err());
}
}
fn temporary_directory() -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"shacraft-account-test-{}-{}",
@@ -354,34 +405,4 @@ mod tests {
);
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("зарезервирован"));
}
}
+355
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+94
View File
@@ -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());
}
}
+294
View File
@@ -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;
+204
View File
@@ -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());
}
+8 -13
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ShaCraft Launcher",
"version": "0.1.6",
"version": "0.2.0",
"identifier": "ru.shacraft.launcher",
"build": {
"beforeDevCommand": "npm run dev",
@@ -28,7 +28,6 @@
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"targets": "all",
"icon": [
"icons/32x32.png",
@@ -37,22 +36,18 @@
"icons/icon.icns",
"icons/icon.ico"
],
"linux": {
"deb": {
"depends": [
"libwebkit2gtk-4.1-0",
"libgtk-3-0",
"pkexec"
]
"windows": {
"wix": {
"upgradeCode": "2058b1df-56a1-51ef-bd48-d296479cd59a"
},
"nsis": {
"installMode": "currentUser"
}
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDk0N0MzODEwMjg1OUVCNDEKUldSQjYxa29FRGg4bEdKSkFWUzZUNDZhRFN4cGIwL0FvVnl0blhrOWtSMWhSOWxHMkU1aGs5L2oK",
"endpoints": [
"https://shacraft.ru/launcher/updates/stable.json"
],
"pubkey": "",
"windows": {
"installMode": "passive"
}
-17
View File
@@ -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
View File
@@ -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"
}
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IHNpZ25hdHVyZSBmcm9tIHRhdXJpIHNlY3JldCBrZXkKUlVRcmJOMjVQU1ZheUVnVElINVRoVDVpb2VGV21XT0wzYXN1WmxxTEhGZHVvYzg3S1FVcUFPVnVqa3FpRXFWbFl2T2FHN2ZmOU1FRVpQR1krREhCcU00RVpXMXYzd2s5UndnPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzg4OTcwNzIxCWZpbGU6bGF0ZXN0Lmpzb24KVkxhbGNUZE9NSUpTWlR3L1hDczFTTkFzbjkrK1ZyWE9EeG5xTUVBUnRYcW1kZHphNXVTTEs4OTU0WitORWdQdk9PaXNhVWVvbTFJRHFBeWRtQmdzQXc9PQo=
+2
View File
@@ -0,0 +1,2 @@
ShaCraft synthetic fixture; never execute or install.
shacraft-launcher_0.3.0_linux-x86_64.AppImage
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM4NUEyNTNEQjlERDZDMkIKUldRcmJOMjVQU1ZheUQ2UzVTN0NHS21ydFp1c1REajVucjlXYnFPK3ZSYWYrQVFDaUgvL3lpQ2UK
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIyODAzRjlGOUFFNDM4QzYKUldUR09PU2FueitBSWhoU1Y4S2VFK21OeWRmamVkMlBreWRjbWdtRmNtbEZrTzMwNE92MDU2d3YK
+35 -23
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { FeedbackDialog, type Feedback } from './components/FeedbackDialog'
import { useCallback, useState } from 'react'
import { LegacyModsDialog } from './components/LegacyModsDialog'
import { Library } from './components/Library'
import { RecoveryCodesModal } from './components/RecoveryCodesModal'
import { PlayDock } from './components/PlayDock'
@@ -9,9 +9,9 @@ import { Titlebar } from './components/Titlebar'
import { servers } from './data/servers'
import { useAccount } from './hooks/useAccount'
import { useLauncher } from './hooks/useLauncher'
import { useLauncherUpdate } from './hooks/useLauncherUpdate'
import { useServerStatus } from './hooks/useServerStatus'
import { useSettings } from './hooks/useSettings'
import { useUpdater } from './hooks/useUpdater'
import { isNative } from './services/native'
import { launchAccess } from './state/account'
import { installStageLabels } from './state/game'
@@ -19,8 +19,8 @@ import { installStageLabels } from './state/game'
export function App() {
const [selected, setSelected] = useState(servers[0])
const [settingsOpen, setSettingsOpen] = useState(false)
const [legacyOpen, setLegacyOpen] = useState(false)
const [windowError, setWindowError] = useState<string | null>(null)
const [errorFeedback, setErrorFeedback] = useState<Feedback | null>(null)
const preferences = useSettings()
const session = useAccount()
const launcher = useLauncher()
@@ -28,25 +28,32 @@ export function App() {
const closeSettings = useCallback(() => setSettingsOpen(false), [])
const desktop = isNative()
const profile = launcher.profiles[selected.profileId]
const metadata = launcher.metadata[selected.profileId]
const displayed = { ...selected, version: metadata ? `Minecraft ${metadata.minecraftVersion}` : 'Версия уточняется',
loader: metadata ? `${metadata.loaderKind} ${metadata.loaderVersion}` : 'По подписанной сборке' }
const ready = profile?.inspection?.upToDate === true
const operation = launcher.game.operation
const busy = operation.phase !== 'idle'
const access = launchAccess(session.account)
const checking = desktop && (!profile || profile.status === 'checking')
const settingsBlocked = !preferences.loaded || preferences.saving || !!preferences.error
const updater = useLauncherUpdate(busy || session.busy || preferences.saving || session.recoveryCodes.length > 0)
const updateLocked = updater.locksOperations
const updaterBlocked = busy ? 'Завершите игру или дождитесь окончания работы со сборкой.'
: settingsBlocked ? 'Дождитесь сохранения настроек; при ошибке повторите сохранение.'
: session.busy || access === 'loading' ? 'Дождитесь завершения работы с аккаунтом.'
: session.linking ? 'Завершите подтверждение игрового ника перед обновлением лаунчера.'
: session.recoveryCodes.length ? 'Сначала сохраните коды восстановления аккаунта.'
: legacyOpen ? 'Закройте проверку старых модов перед обновлением лаунчера.' : null
const updater = useUpdater(updaterBlocked)
const updaterRecovery = updater.state.status?.phase === 'error' && !updater.state.status.canRetry
const updateLocked = updater.mutating || updater.state.status?.phase === 'ready' || updaterRecovery
const disabled = !desktop || busy || updateLocked || session.busy || access === 'loading' ||
(access === 'ready' && (checking || settingsBlocked || !launcher.eventsReady))
const repairDisabled = !desktop || busy || updateLocked || checking
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 = 'Играть'
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 === 'launching') label = 'Запускаем…'
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 (!ready) label = 'Проверить'
const onboard = async (nickname: string) => {
if (busy || updateLocked || settingsBlocked || !launcher.eventsReady) return
const result = await launcher.onboard(selected.profileId, nickname)
if (result?.onboarding) session.acceptChallenge(result.onboarding)
}
const primary = () => {
if (disabled) return
if (access !== 'ready') setSettingsOpen(true)
@@ -68,28 +80,28 @@ export function App() {
return (
<div className="app-shell">
<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}
native={desktop} locked={busy || updateLocked || session.busy} onSelect={setSelected} onSettings={() => setSettingsOpen(true)} />
<ServerStage server={selected} status={serverStatus}>
<PlayDock server={selected} operation={operation} profile={profile}
<ServerStage server={displayed} status={serverStatus} javaMajor={metadata?.javaMajor}>
{(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}
needsLogin={access === 'login'} needsLink={access === 'link'}
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>
</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}
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} />
<FeedbackDialog feedback={session.recoveryCodes.length ? null : session.feedback ?? errorFeedback}
onDismiss={() => { session.dismissFeedback(); setErrorFeedback(null) }} />
</div>
)
}
+12 -6
View File
@@ -3,7 +3,7 @@ import { LogOut, Users } from 'lucide-react'
import type { useAccount } from '../hooks/useAccount'
import { isNative } from '../services/native'
export function AccountSettings({ session, locked }: { session: ReturnType<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 [password, setPassword] = useState('')
const [registering, setRegistering] = useState(false)
@@ -41,19 +41,25 @@ export function AccountSettings({ session, locked }: { session: ReturnType<typeo
</form>
)}
{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">
<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
disabled={disabled || session.linking} onChange={(event) => setNickname(event.target.value)} placeholder="Player" />
<small>Свободный ник закрепляется за аккаунтом. Для старого игрового ника обратитесь к администратору.</small>
<small>Подтвердите владение ником на сервере Aeronautics.</small>
</label>
<button className="setting-row" type="submit" disabled={disabled || session.linking}>
<span>{session.linking ? 'Ожидаем подтверждения…' : busy ? 'Создаём проверку…' : 'Привязать ник'}</span>
<span>{session.linking ? 'Ожидаем подтверждения…' : 'Привязать ник через открытую игру'}</span>
</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>
)}
{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.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>}
-34
View File
@@ -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>
)
}
+76
View File
@@ -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>
)
}
-56
View File
@@ -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>
}
+96
View File
@@ -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>
}
+2 -2
View File
@@ -24,7 +24,7 @@ export function Library({ selected, profiles, account, locked, native, onSelect,
<button className="rail-button active" aria-label="Настройки" onClick={onSettings}><Settings /></button>
</nav>
<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">
{servers.map((server) => {
const profile = profiles[server.profileId]
@@ -33,7 +33,7 @@ export function Library({ selected, profiles, account, locked, native, onSelect,
return (
<button key={server.id} className={`server-row ${selected.id === server.id ? 'selected' : ''}`}
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>
<ChevronRight size={16} />
</button>
+6 -2
View File
@@ -18,6 +18,7 @@ interface PlayDockProps {
repairDisabled: boolean
onPrimary: () => void
onRepair: () => void
onLegacy: () => void
}
export function PlayDock(props: PlayDockProps) {
@@ -41,7 +42,9 @@ export function PlayDock(props: PlayDockProps) {
} else {
title = needsLogin ? 'Нужен вход ShaCraft' : props.needsLink ? 'Нужно привязать ник' : profile?.status === 'checking' ? 'Проверяем сборку'
: profile?.inspection?.upToDate ? 'Сборка готова' : 'Требуется проверка'
detail = profile?.inspection ? `${profile.inspection.managedFiles} файлов под контролем` : 'Проверяем локальные файлы'
detail = profile?.inspection
? `${profile.inspection.upToDate ? 'Проверено файлов сборки' : 'Файлов в сборке'}: ${profile.inspection.managedFiles}`
: 'Проверяем локальные файлы'
}
return (
@@ -58,7 +61,8 @@ export function PlayDock(props: PlayDockProps) {
<span><Globe2 size={15} /> {server.version}</span>
<span><Gauge size={15} /> {memoryGb} ГБ памяти</span>
</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}>
<Play size={21} fill="currentColor" /><span>{props.label}</span>
</button>
+2 -2
View File
@@ -3,7 +3,7 @@ import { Users } from 'lucide-react'
import { isNative } from '../services/native'
import type { Server, ServerStatus } from '../types/launcher'
export function ServerStage({ server, status, children }: { server: Server; status: ServerStatus | null; children: ReactNode }) {
export function ServerStage({ server, status, javaMajor, children }: { server: Server; status: ServerStatus | null; javaMajor?: number; children: ReactNode }) {
const online = status?.reachable && status.online !== null && status.max !== null
? `${status.online} / ${status.max}` : !isNative() ? 'В приложении' : status === null ? 'Проверяем…' : 'Нет связи'
const label = !isNative() ? 'Статус в приложении' : status === null ? 'Проверяем сервер' : status.reachable ? 'Сервер доступен' : 'Сервер недоступен'
@@ -17,7 +17,7 @@ export function ServerStage({ server, status, children }: { server: Server; stat
<p>{server.kicker}</p><h1>{server.name}</h1><h2>{server.subtitle}</h2>
<dl className="hero-meta">
<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>
</section>
{children}
+18 -11
View File
@@ -1,10 +1,10 @@
import { useEffect, useRef } from 'react'
import { FolderOpen, Wrench, X } from 'lucide-react'
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 { useSettings } from '../hooks/useSettings'
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
import type { JavaInstallation, NativeHost } from '../types/launcher'
interface SettingsDrawerProps {
@@ -14,11 +14,14 @@ interface SettingsDrawerProps {
java: JavaInstallation | null | undefined
preferences: ReturnType<typeof useSettings>
session: ReturnType<typeof useAccount>
updater: ReturnType<typeof useLauncherUpdate>
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 closeButton = useRef<HTMLButtonElement>(null)
useEffect(() => {
@@ -28,7 +31,9 @@ export function SettingsDrawer({ open, locked, host, java, preferences, session,
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
if (event.key !== 'Tab') return
const elements = closeButton.current?.closest('aside')?.querySelectorAll<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 last = elements?.[elements.length - 1]
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}
role="dialog" aria-modal={open ? true : undefined} aria-labelledby="settings-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>
</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">
<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}
onChange={(event) => preferences.updateRam(Number(event.target.value))} />
<small>Для Aeronautics рекомендуется 6 ГБ</small>
</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><Wrench />Java</span>
<small>{java === undefined ? host ? 'Проверяем…' : 'Проверяется в приложении'
: java?.major === 21 ? 'Java 21 найдена' : java ? `Нужна Java 21 · найдена ${java.major}`
: 'Лаунчер установит Java 21 автоматически'}</small>
: requiredJava === undefined ? 'Требование Java уточняется по сборке'
: java?.major === requiredJava ? `Java ${requiredJava} найдена` : java ? `Нужна Java ${requiredJava} · найдена ${java.major}`
: `Лаунчер установит Java ${requiredJava} автоматически`}</small>
</div>
<div className="settings-feedback" aria-live="polite">
{error && <><p className="status-error">{error}</p><button onClick={preferences.retry} disabled={locked || saving}>Повторить</button></>}
{saving && <p>Сохраняем настройки</p>}
</div>
<div className="drawer-note">{host ? `Данные лаунчера: ${host.dataDir}` : 'Java 21 будет управляться лаунчером автоматически.'}</div>
<div className="drawer-note">{host ? `Данные лаунчера: ${host.dataDir}` : 'Java будет подобрана по подписанной сборке.'}</div>
</aside>
</>
)
+50 -14
View File
@@ -3,17 +3,18 @@ import { test } from 'node:test'
import { renderToStaticMarkup } from 'react-dom/server'
import { AccountSettings } from './AccountSettings'
import { RecoveryCodesModal } from './RecoveryCodesModal'
import { ServerStage } from './ServerStage'
import { Library } from './Library'
import { LegacyModsDialog } from './LegacyModsDialog'
import { PlayDock } from './PlayDock'
import { launchAccess } from '../state/account'
import { servers } from '../data/servers'
import type { useAccount } from '../hooks/useAccount'
function session(): ReturnType<typeof useAccount> {
return {
account: null, error: null, busy: false, recoveryCodes: [], linkMessage: null,
feedback: null, dismissFeedback: () => {},
linking: false, linkedNickname: null, authenticate: async () => true,
logout: async () => {}, startLink: async () => {}, clearError: () => {},
acceptChallenge: () => {},
acknowledgeRecoveryCodes: () => {},
}
}
@@ -34,7 +35,7 @@ test('linked identity is displayed read-only while an unlinked account offers ve
doesNotMatch(linked, /<input|Привязать ник/)
const unlinked = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: { username: 'website_login', links: [] } }} locked={false} />)
match(unlinked, /Привязать ник/)
match(unlinked, /Общий ник ShaCraft/)
match(unlinked, /Aeronautics/)
})
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={() => {}} />), '')
})
test('both profiles remain selectable and display their own runtime requirements', () => {
const library = renderToStaticMarkup(<Library selected={servers[1]!} profiles={{}} account={null}
locked={false} native={false} onSelect={() => {}} onSettings={() => {}} />)
match(library, /Aeronautics/)
match(library, /Minigames/)
equal((library.match(/class="server-row/g) ?? []).length, 2)
const stage = (index: number) => renderToStaticMarkup(<ServerStage server={servers[index]!} status={null}>{null}</ServerStage>)
match(stage(0), /Версия 21/)
match(stage(1), /Версия 25/)
match(stage(1), /Fabric 0.19.5/)
test('onboarding is visible only for a signed-in unlinked account and needs an explicit nickname', () => {
const onOnboard = async () => {}
const unlinked = { username: 'website_login', links: [] }
const html = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: unlinked }} locked={false} onOnboard={onOnboard} />)
match(html, /Установить и войти для подтверждения/)
match(html, /<button[^>]*type="button"[^>]*disabled=""[^>]*><span>Установить и войти для подтверждения/)
match(html, /minLength="3" maxLength="16" pattern="\[A-Za-z0-9_\]\{3,16\}" required=""/)
equal(launchAccess(unlinked), 'link')
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=""/)
})
+73 -62
View File
@@ -1,75 +1,86 @@
import { doesNotMatch, match } from 'node:assert/strict'
import { test } from 'node:test'
import { renderToStaticMarkup } from 'react-dom/server'
import { LauncherUpdateSettings } from './LauncherUpdateSettings'
import type { useLauncherUpdate } from '../hooks/useLauncherUpdate'
import { initialUpdaterState, updaterReducer, updateBlocksOperations, type UpdaterState } from '../state/updater'
import { LauncherUpdate } from './LauncherUpdate'
import { initialUpdaterState } from '../state/updater'
import type { UpdaterState } from '../state/updater'
import type { UpdaterStatus } from '../types/updater'
function render(state: UpdaterState): string {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
const previousTauri = Object.getOwnPropertyDescriptor(globalThis, 'isTauri')
Object.defineProperty(globalThis, 'window', { configurable: true, value: { isTauri: true } })
Object.defineProperty(globalThis, 'isTauri', { configurable: true, value: true })
try {
const updater: ReturnType<typeof useLauncherUpdate> = {
state, blocked: false, eventsReady: true, eventError: null,
locksOperations: updateBlocksOperations(state), check: async () => {},
install: async () => {}, restart: async () => {},
}
return renderToStaticMarkup(<LauncherUpdateSettings updater={updater} />)
} finally {
if (previous) Object.defineProperty(globalThis, 'window', previous)
else Reflect.deleteProperty(globalThis, 'window')
if (previousTauri) Object.defineProperty(globalThis, 'isTauri', previousTauri)
else Reflect.deleteProperty(globalThis, 'isTauri')
}
function status(phase: UpdaterStatus['phase'], extra: Partial<UpdaterStatus> = {}): UpdaterStatus {
return { revision: 1, installedVersion: '0.2.0', testBuild: false, packageFormat: 'development', phase, availableVersion: null, releaseNotes: null,
downloadedBytes: 0, totalBytes: null, canRetry: false, message: null, ...extra }
}
function render(value: UpdaterStatus, extra: Partial<UpdaterState> = {}, native = true) {
return renderToStaticMarkup(<LauncherUpdate state={{ ...initialUpdaterState, status: value, ...extra }} native={native}
onCheck={() => {}} onInstall={() => {}} onRestart={() => {}} onOpenRelease={() => {}} />)
}
const available = updaterReducer(initialUpdaterState, { type: 'loaded', checked: true,
status: { currentVersion: '0.1.4', supported: true, installationKind: 'deb', version: '0.1.5' } })
test('deb announces system authorization before installation without collecting credentials', () => {
const html = render(available)
match(html, /Для обновления deb потребуется подтверждение администратора в системном окне/)
match(html, /class="update-primary"><[^>]+.*Обновить<\/button>/)
doesNotMatch(html, /<input|type="password"|Перезапустить лаунчер/)
test('available updater notes are plain text and installation explicitly includes restart', () => {
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/)
match(html, /Новая версия: <strong>0\.3\.0/)
match(html, /Установить и перезапустить/)
match(html, /Установка закроет и перезапустит лаунчер/)
match(html, /&lt;img src=x/)
doesNotMatch(html, /<img|<a\s|dangerouslySetInnerHTML|authenticode|notarization/i)
})
test('deb installation requests system confirmation and prevents duplicate install or check', () => {
const downloading = updaterReducer(available, { type: 'install' })
const installing = updaterReducer(downloading, { type: 'progress',
progress: { stage: 'installing', downloadedBytes: 10, totalBytes: 10 } })
const html = render(installing)
match(html, /Подтвердите установку в системном окне и дождитесь завершения/)
match(html, /aria-label="Установка обновления лаунчера"/)
match(html, /class="update-primary" disabled=""/)
match(html, /class="update-check" disabled=""/)
doesNotMatch(html, /Для обновления deb потребуется|<input|type="password"/)
test('an unconfigured build still reports its installed native version', () => {
const html = render(status('unconfigured'))
match(html, /Версия 0\.2\.0/)
match(html, /Автообновление пока не настроено/)
doesNotMatch(html, /Установить и перезапустить/)
})
test('cancelled deb authorization remains visible and permits explicit retry without claiming success', () => {
const downloading = updaterReducer(available, { type: 'install' })
const installing = updaterReducer(downloading, { type: 'progress',
progress: { stage: 'installing', downloadedBytes: 10 } })
const cancelled = updaterReducer(installing, { type: 'failed', error: 'Установка отменена в системном окне.' })
const html = render(cancelled)
match(html, /role="status">Установка отменена в системном окне/)
match(html, /class="update-primary">/)
match(html, /Повторить проверку/)
doesNotMatch(html, /disabled=""|Обновление установлено|Перезапустить лаунчер/)
const retry = render(updaterReducer(cancelled, { type: 'install' }))
match(retry, /Скачиваем обновление/)
doesNotMatch(retry, /Установка отменена/)
test('test updater builds have a visible native-provided notice', () => {
match(render(status('available', { testBuild: true })), /Тестовая сборка · тестовый канал обновлений/)
doesNotMatch(render(status('available')), /Тестовая сборка/)
})
test('AppImage and older native status retain their installation copy without administrator hints', () => {
for (const installationKind of ['appimage', undefined] as const) {
const status = { ...available.status!, installationKind }
const downloading = updaterReducer({ ...available, status }, { type: 'install' })
const installing = updaterReducer(downloading, { type: 'progress',
progress: { stage: 'installing', downloadedBytes: 10 } })
const html = render(installing)
match(html, /Проверяем подпись и устанавливаем/)
doesNotMatch(html, /администратора|системном окне/)
}
test('manual Linux packages offer release instructions without a native install button or frontend link', () => {
const html = render(status('manual', { availableVersion: '0.3.0', packageFormat: 'deb' }))
match(html, /Версия 0\.2\.0 · deb/)
match(html, /системный менеджер пакетов/)
match(html, /Открыть страницу выпусков/)
doesNotMatch(html, /Установить и перезапустить|href=/)
})
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
View File
@@ -8,13 +8,8 @@ export const servers: readonly [Server, ...Server[]] = [
kicker: 'Основная сборка',
name: 'Aeronautics',
subtitle: 'Строй корабли. Поднимай города в небо.',
version: '1.21.1 · NeoForge 21.1.248',
loader: 'NeoForge 21.1.248',
version: 'Версия уточняется',
loader: 'По подписанной сборке',
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
View File
@@ -1,10 +1,15 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { Feedback } from '../components/FeedbackDialog'
import { useEffect, useRef, useState } from 'react'
import { createRequestScope, errorMessage } from '../services/async'
import { isNative, native } from '../services/native'
import { linkedNickname, validCredentials } from '../state/account'
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() {
// undefined = restoring saved account; null = signed out.
@@ -12,12 +17,8 @@ export function useAccount() {
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
const [challenge, setChallenge] = useState<PendingLink | 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 requests = useRef(createRequestScope())
@@ -34,6 +35,44 @@ export function useAccount() {
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) => {
if (pending.current || account === undefined) return false
if (!validCredentials(username, password)) {
@@ -53,6 +92,7 @@ export function useAccount() {
const result = await native.authenticate(username, password, register)
if (!currentRequest()) return false
setAccount(result.account)
setChallenge(null)
setLinkMessage(null)
setRecoveryCodes(result.recoveryCodes)
return true
@@ -69,6 +109,7 @@ export function useAccount() {
if (!isNative() || pending.current) return
pending.current = true
requests.current.invalidate()
setChallenge(null)
setLinkMessage(null)
setBusy(true)
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) => {
if (pending.current) return
if (!isNative()) {
reportLink('Привязка доступна в приложении лаунчера.', 'error')
return
}
if (!account) {
reportLink('Войдите в аккаунт ShaCraft, затем повторите привязку.', 'error')
return
}
nickname = nickname.trim()
if (!isNative() || pending.current || challenge || !account) return
if (!isValidNickname(nickname)) {
reportLink('Ник: 3–16 латинских букв, цифр или _', 'error')
setLinkMessage('Ник: 3–16 латинских букв, цифр или _')
return
}
pending.current = true
setBusy(true)
setError(null)
reportLink('Проверяем аккаунт и закрепляем ник…')
setLinkMessage('Создаём проверку…')
requests.current.invalidate()
const currentRequest = requests.current.capture()
try {
const refreshed = await native.getAccount()
const started = await native.startLink(nickname)
if (!currentRequest()) return
setAccount(refreshed)
if (!refreshed) {
reportLink('Сессия завершена или аккаунт удалён. Войдите в ShaCraft снова; если аккаунт удалён, создайте новый.', 'error')
return
}
const linkedAccount = await native.claimNickname(nickname)
if (!currentRequest()) return
setAccount(linkedAccount)
const confirmed = linkedNickname(linkedAccount)
reportLink(confirmed ? `Ник ${confirmed} закреплён за аккаунтом. Теперь можно запускать игру.`
: 'Не удалось получить закреплённый ник. Войдите снова.', confirmed ? 'success' : 'error')
acceptChallenge(started)
} catch (reason) {
if (currentRequest()) reportLink(errorMessage(reason, 'Не удалось начать привязку'), 'error')
setLinkMessage(errorMessage(reason, 'Не удалось начать привязку'))
} finally {
pending.current = false
setBusy(false)
@@ -128,9 +157,8 @@ export function useAccount() {
}
return {
account, error, busy, recoveryCodes, linkMessage, linking: false,
feedback, dismissFeedback: () => setFeedback(null),
linkedNickname: linkedNickname(account), authenticate, logout, startLink,
account, error, busy, recoveryCodes, linkMessage, linking: challenge !== null,
linkedNickname: linkedNickname(account), authenticate, logout, startLink, acceptChallenge,
clearError: () => setError(null),
acknowledgeRecoveryCodes: () => setRecoveryCodes([]),
}
+28 -42
View File
@@ -4,7 +4,7 @@ import { errorMessage } from '../services/async'
import { isNative, native, watchGame } from '../services/native'
import { gameReducer, initialGameState } from '../state/game'
import { profilesReducer } from '../state/profiles'
import type { JavaInstallation, NativeHost } from '../types/launcher'
import type { JavaInstallation, NativeHost, ProfileMetadata, PreparationResult } from '../types/launcher'
export function useLauncher() {
const [host, setHost] = useState<NativeHost | null>(null)
@@ -14,6 +14,7 @@ export function useLauncher() {
const [game, dispatch] = useReducer(gameReducer, initialGameState)
const [eventsReady, setEventsReady] = useState(false)
const busy = useRef(false)
const [metadata, setMetadata] = useState<Record<string, ProfileMetadata>>({})
useEffect(() => {
if (!isNative()) return
@@ -41,6 +42,9 @@ export function useLauncher() {
})
for (const server of servers) {
const profileId = server.profileId
void native.metadata(profileId).then((value) => {
if (active) setMetadata((old) => ({ ...old, [profileId]: value }))
}).catch(() => { /* Missing verified metadata is shown as unknown. */ })
updateProfile({ type: 'check', profileId })
void native.inspectProfile(profileId).then((inspection) => {
if (active) updateProfile({ type: 'checked', profileId, inspection })
@@ -51,56 +55,38 @@ export function useLauncher() {
return () => { active = false; subscription.dispose() }
}, [])
const repair = async (profileId: string) => {
if (!isNative() || busy.current || game.operation.phase !== 'idle' || profiles[profileId]?.status === 'checking') return
const prepare = async (profileId: string, mode: 'repair' | 'play' | 'onboarding', nickname?: string): Promise<PreparationResult | null> => {
if (!isNative() || !eventsReady || busy.current || game.operation.phase !== 'idle') return null
busy.current = true
dispatch({ type: 'sync', profileId })
// A repair may replace only some files before failing. Never keep an older
// up-to-date inspection as permission to launch that partial installation.
dispatch({ type: 'install', profileId })
updateProfile({ type: 'check', profileId })
try {
const result = await native.syncProfile(profileId)
updateProfile({ type: 'checked', profileId,
inspection: { root: result.root, managedFiles: result.downloadedFiles + result.reusedFiles,
missingFiles: 0, mismatchedFiles: 0, upToDate: true },
})
dispatch({ type: 'synced', profileId })
// The native command owns one snapshot and one lock across every stage.
const result = mode === 'repair' ? await native.installGame(profileId)
: mode === 'onboarding' ? await native.launchOnboarding(profileId, nickname ?? '')
: await native.launchGame(profileId)
updateProfile({ type: 'checked', profileId, inspection: result.inspection })
setMetadata((old) => ({ ...old, [profileId]: result.metadata }))
if (mode === 'repair') dispatch({ type: 'repaired', profileId })
else dispatch({ type: 'started', profileId })
return result
} catch (reason) {
const error = errorMessage(reason, 'Не удалось синхронизировать сборку')
const error = errorMessage(reason, mode === 'repair' ? 'Не удалось восстановить игру' : 'Не удалось запустить игру')
updateProfile({ type: 'failed', profileId, error })
dispatch({ type: 'failed', error })
} finally {
busy.current = false
}
return null
} finally { busy.current = false }
}
const launch = async (profileId: string) => {
if (!isNative() || !eventsReady || busy.current || game.operation.phase !== 'idle') return
busy.current = true
dispatch({ type: 'sync', profileId })
const refreshProfile = async (profileId: string) => {
updateProfile({ type: 'check', profileId })
try {
// Reconcile the current signed modpack before every Play, even when a
// previous inspection succeeded. Game installation alone omits mods.
const synced = await native.syncProfile(profileId)
updateProfile({ type: 'checked', profileId, inspection: {
root: synced.root, managedFiles: synced.downloadedFiles + synced.reusedFiles,
missingFiles: 0, mismatchedFiles: 0, upToDate: true,
} })
dispatch({ type: 'synced', profileId })
dispatch({ type: 'install', profileId })
await native.installGame(profileId)
dispatch({ type: 'launch', profileId })
await native.launchGame(profileId)
dispatch({ type: 'started', profileId })
} catch (reason) {
const error = errorMessage(reason, 'Не удалось запустить игру')
updateProfile({ type: 'failed', profileId, error })
dispatch({ type: 'failed', error })
} finally {
busy.current = false
}
try { updateProfile({ type: 'checked', profileId, inspection: await native.inspectProfile(profileId) }) }
catch (reason) { updateProfile({ type: 'failed', profileId, error: errorMessage(reason, 'Не удалось проверить сборку') }) }
}
return { host, java, environmentError, profiles, game, eventsReady, repair, launch }
return { host, java, environmentError, profiles, metadata, game, eventsReady, refreshProfile,
repair: (profileId: string) => prepare(profileId, 'repair'),
launch: (profileId: string) => prepare(profileId, 'play'),
onboard: (profileId: string, nickname: string) => prepare(profileId, 'onboarding', nickname),
}
}
-83
View File
@@ -1,83 +0,0 @@
import { useEffect, useReducer, useRef, useState } from 'react'
import { errorMessage, singleFlight } from '../services/async'
import { isNative, native, watchLauncherUpdate } from '../services/native'
import { initialUpdaterState, updateBlocksOperations, updaterReducer } from '../state/updater'
// StrictMode re-runs effects; share the pending native check without installing
// anything. Manual checks always make a fresh request.
const startupCheck = singleFlight(async () => {
const status = await native.updateStatus()
if (!status.supported || (status.stage && ['downloading', 'installing', 'ready'].includes(status.stage))) return { status, checked: false, error: null }
try {
return { status: await native.checkUpdate(), checked: true, error: null }
} catch (error) {
return { status, checked: false, error: errorMessage(error, 'Не удалось проверить обновления. Повторите попытку.') }
}
})
export function useLauncherUpdate(blocked: boolean) {
const [state, dispatch] = useReducer(updaterReducer, initialUpdaterState)
const [eventsReady, setEventsReady] = useState(false)
const [eventError, setEventError] = useState<string | null>(null)
const pending = useRef(false)
const mounted = useRef(false)
useEffect(() => {
if (!isNative()) return
let active = true
mounted.current = true
const subscription = watchLauncherUpdate((progress) => {
if (active) dispatch({ type: 'progress', progress })
})
void subscription.ready.then(() => {
if (active) { setEventsReady(true); setEventError(null) }
}).catch((reason: unknown) => {
if (active) setEventError(errorMessage(reason, 'Не удалось подключить события обновления. Перезапустите лаунчер.'))
})
pending.current = true
void startupCheck().then((result) => {
if (active) dispatch({ type: 'loaded', ...result })
}).catch((reason: unknown) => {
if (active) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось проверить обновления. Повторите попытку.') })
}).finally(() => { if (active) pending.current = false })
return () => { active = false; mounted.current = false; subscription.dispose() }
}, [])
const check = async () => {
if (!isNative() || pending.current || updateBlocksOperations(state)) return
pending.current = true
dispatch({ type: 'check' })
try {
const status = await native.checkUpdate()
if (mounted.current) dispatch({ type: 'loaded', status, checked: true })
} catch (reason) {
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось проверить обновления. Повторите попытку.') })
} finally { pending.current = false }
}
const install = async () => {
if (!isNative() || pending.current || blocked || !eventsReady || state.phase !== 'idle' ||
!state.status?.supported || !state.status.version) return
pending.current = true
dispatch({ type: 'install' })
try {
await native.installUpdate()
if (mounted.current) dispatch({ type: 'ready' })
} catch (reason) {
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось установить обновление. Повторите попытку.') })
} finally { pending.current = false }
}
const restart = async () => {
if (!isNative() || pending.current || blocked || state.phase !== 'ready') return
pending.current = true
dispatch({ type: 'restart' })
try {
await native.restartAfterUpdate()
} catch (reason) {
if (mounted.current) dispatch({ type: 'failed', error: errorMessage(reason, 'Не удалось перезапустить лаунчер. Закройте его и откройте снова.') })
} finally { pending.current = false }
}
return { state, eventsReady, eventError, blocked, locksOperations: updateBlocksOperations(state), check, install, restart }
}

Some files were not shown because too many files have changed in this diff Show More