Support signed deb self-updates with system authorization in 0.1.4
This commit is contained in:
@@ -72,8 +72,21 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
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 is supported for AppImage only. Preserve executable
|
||||
permissions and use same-directory atomic replacement after verification.
|
||||
- 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.
|
||||
@@ -122,6 +135,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
must remain outside its argument substitution and JVM argfile paths.
|
||||
- `updater.rs`, `commands/updater.rs` — authenticated release metadata,
|
||||
bounded package download, platform installation and guarded restart.
|
||||
- `deb_updater.rs` — installed-package checks, one system authentication
|
||||
prompt and the bounded, signature-verifying non-GUI root helper.
|
||||
- `src-tauri/src/settings.rs` — durable local preferences; maintain backward
|
||||
compatibility with already-written JSON.
|
||||
- `docs/manifest-v1.md` — signed manifest envelope and payload contract
|
||||
|
||||
@@ -91,6 +91,30 @@
|
||||
- [ ] Проверить установку и самообновление 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 обязательны
|
||||
|
||||
@@ -48,8 +48,11 @@ push/PR; workflow на main-push/ручном запуске собирает Wi
|
||||
их только по кнопке. Подписи пакета и сведений о версии обязательны.
|
||||
Закройте запущенный Minecraft перед установкой обновления.
|
||||
|
||||
В Linux используйте AppImage; deb и dev-бинарник обновляются вручную.
|
||||
С версии 0.1.2 нужен один ручной переход на новый AppImage. Пакеты Windows/macOS
|
||||
В Linux обновляются AppImage и установленный deb (с версии 0.1.4).
|
||||
Для deb система запрашивает права администратора; пароль не передаётся лаунчеру.
|
||||
Deb 0.1.3 и ниже нужно один раз обновить вручную до 0.1.4. Dev-бинарник
|
||||
обновляется вручную. С версии 0.1.2 нужен ручной переход на новый AppImage.
|
||||
Пакеты Windows/macOS
|
||||
с этим механизмом ещё требуют публикации и проверки установки.
|
||||
|
||||
## Навигация
|
||||
|
||||
@@ -23,7 +23,8 @@ 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.
|
||||
Linux AppImage replacement is supported; the first upgrade from 0.1.2 is manual.
|
||||
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 packages still require publication and actual installation tests.
|
||||
Not yet implemented: a user-selectable profile directory and a "reset managed
|
||||
files only" recovery action. OS code signing/notarization is separate from the
|
||||
@@ -195,6 +196,24 @@ 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.
|
||||
@@ -248,3 +267,19 @@ 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.
|
||||
|
||||
+85
-14
@@ -9,9 +9,13 @@ 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. On Linux, automatic
|
||||
replacement is for AppImage installations; deb installations and development
|
||||
binaries use the manual download path. Windows/macOS publication and actual
|
||||
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.
|
||||
|
||||
@@ -22,7 +26,7 @@ The archived 2026-09-09 review bundle at
|
||||
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 current 0.1.3 release follows the deployed 0.1.2
|
||||
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
|
||||
@@ -56,13 +60,65 @@ 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` (`.AppImage`), `windows-x86_64` (`.exe` or `.msi`),
|
||||
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
|
||||
@@ -88,33 +144,40 @@ package signatures; passing updater checks does not establish those assurances.
|
||||
|
||||
## Local preparation and signing
|
||||
|
||||
The publisher requires Python 3.10+ and `minisign`. It performs verification
|
||||
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 the Linux artifact already exists at
|
||||
`/tmp/shacraft-release/downloads/0.1.3/ShaCraft.Launcher_0.1.3_amd64.AppImage`
|
||||
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.3/ShaCraft.Launcher_0.1.3_amd64.AppImage
|
||||
/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. Do not list a deb, dmg, nonexistent package or untested architecture:
|
||||
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.3 \
|
||||
--version 0.1.4 \
|
||||
--downloads-root /tmp/shacraft-release/downloads \
|
||||
--artifact linux-x86_64=ShaCraft.Launcher_0.1.3_amd64.AppImage \
|
||||
--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
|
||||
@@ -173,8 +236,12 @@ 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. Unit tests,
|
||||
packaging or a browser mock alone do not establish successful installation.
|
||||
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.
|
||||
|
||||
@@ -187,6 +254,10 @@ 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.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "shacraft-launcher-ui",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shacraft-launcher-ui",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.11.1",
|
||||
"lucide-react": "1.41.0",
|
||||
|
||||
+2
-2
@@ -2,13 +2,13 @@
|
||||
"name": "shacraft-launcher-ui",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "npm run typecheck && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts src/state/account.test.ts src/components/account.test.tsx src/state/updater.test.ts",
|
||||
"test": "tsx --test src/services/async.test.ts src/state/game.test.ts src/state/profiles.test.ts src/state/account.test.ts src/components/account.test.tsx src/state/updater.test.ts src/components/updater.test.tsx",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
|
||||
@@ -15,13 +15,17 @@ 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",),
|
||||
@@ -29,6 +33,9 @@ PLATFORMS = {
|
||||
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):
|
||||
@@ -110,6 +117,67 @@ def verify_signature(artifact, signature, public_key, minisign):
|
||||
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 = {}
|
||||
@@ -163,6 +231,7 @@ def validate_payload(payload, downloads_root, public_key, minisign):
|
||||
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")
|
||||
@@ -177,6 +246,7 @@ def validate_payload(payload, downloads_root, public_key, minisign):
|
||||
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
|
||||
|
||||
@@ -7,6 +7,7 @@ import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
@@ -15,6 +16,14 @@ 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"))
|
||||
@@ -24,10 +33,14 @@ class PolicyTests(unittest.TestCase):
|
||||
|
||||
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)
|
||||
@@ -36,6 +49,20 @@ class PolicyTests(unittest.TestCase):
|
||||
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):
|
||||
@@ -54,7 +81,7 @@ class SignatureTests(unittest.TestCase):
|
||||
release = self.downloads / "0.1.3"
|
||||
release.mkdir(parents=True)
|
||||
self.artifact = release / "fixture.AppImage"
|
||||
self.artifact.write_bytes(b"isolated ShaCraft updater fixture; not an executable")
|
||||
self.artifact.write_bytes(appimage_fixture())
|
||||
self.payload = {
|
||||
"version": "0.1.3", "notes": "Проверка обновления", "pub_date": "2026-09-10T00:00:00Z",
|
||||
"platforms": {"linux-x86_64": {
|
||||
@@ -151,6 +178,110 @@ class SignatureTests(unittest.TestCase):
|
||||
self.publish(dry_run=True)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def make_deb(self, package="sha-craft-launcher", version=None, architecture="amd64"):
|
||||
if not Path(publisher.DPKG_DEB).is_file():
|
||||
self.skipTest("dpkg-deb required for real deb validation")
|
||||
version = version or self.payload["version"]
|
||||
tree = self.root / "deb-tree"
|
||||
control = tree / "DEBIAN"
|
||||
control.mkdir(parents=True, exist_ok=True)
|
||||
(control / "control").write_text(
|
||||
f"Package: {package}\nVersion: {version}\nArchitecture: {architecture}\n"
|
||||
"Maintainer: Test <test@example.invalid>\nDescription: isolated updater fixture\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
# Inspection must not execute a package script, even for an authenticated package.
|
||||
script = control / "preinst"
|
||||
script.write_text(f"#!/bin/sh\ntouch '{self.root / 'script-executed'}'\n", encoding="ascii")
|
||||
script.chmod(0o755)
|
||||
deb = self.artifact.with_name("fixture.deb")
|
||||
subprocess.run(
|
||||
[publisher.DPKG_DEB, "--build", "--root-owner-group", str(tree), str(deb)],
|
||||
env=publisher.PACKAGE_TOOL_ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
timeout=10, check=True,
|
||||
)
|
||||
self.payload["platforms"]["linux-x86_64-appimage"] = copy.deepcopy(
|
||||
self.payload["platforms"]["linux-x86_64"]
|
||||
)
|
||||
self.payload["platforms"]["linux-x86_64-deb"] = {
|
||||
"url": publisher.ORIGIN + self.payload["version"] + "/fixture.deb", "signature": self.sign(deb),
|
||||
}
|
||||
return deb
|
||||
|
||||
def test_format_aware_release_preserves_legacy_appimage_and_verifies_real_deb(self):
|
||||
self.make_deb()
|
||||
notes = self.root / "notes.txt"
|
||||
notes.write_text(self.payload["notes"], encoding="utf-8")
|
||||
args = argparse.Namespace(
|
||||
version="0.1.3", artifact=[
|
||||
"linux-x86_64=fixture.AppImage", "linux-x86_64-appimage=fixture.AppImage",
|
||||
"linux-x86_64-deb=fixture.deb",
|
||||
], downloads_root=self.downloads, notes_file=notes, payload=self.payload_path,
|
||||
pub_date=self.payload["pub_date"], minisign=MINISIGN,
|
||||
)
|
||||
self.assertEqual(publisher.prepare(args, self.public_key), self.payload)
|
||||
self.publish()
|
||||
feed = publisher.verified_previous(self.output.read_bytes(), self.public_key, MINISIGN)
|
||||
self.assertEqual(feed["platforms"]["linux-x86_64"], feed["platforms"]["linux-x86_64-appimage"])
|
||||
self.assertEqual(set(feed["platforms"]), {"linux-x86_64", "linux-x86_64-appimage", "linux-x86_64-deb"})
|
||||
self.assertFalse((self.root / "script-executed").exists())
|
||||
|
||||
def test_authenticated_legacy_feed_advances_to_format_aware_release(self):
|
||||
self.publish()
|
||||
old_release = self.artifact.parent
|
||||
old_bytes = self.artifact.read_bytes()
|
||||
new_release = self.downloads / "0.1.4"
|
||||
shutil.copytree(old_release, new_release)
|
||||
self.artifact = new_release / self.artifact.name
|
||||
self.payload["version"] = "0.1.4"
|
||||
self.payload["platforms"]["linux-x86_64"]["url"] = publisher.ORIGIN + "0.1.4/fixture.AppImage"
|
||||
self.make_deb()
|
||||
self.publish()
|
||||
feed = publisher.verified_previous(self.output.read_bytes(), self.public_key, MINISIGN)
|
||||
self.assertEqual(feed["version"], "0.1.4")
|
||||
self.assertEqual(len(feed["platforms"]), 3)
|
||||
self.assertEqual((old_release / self.artifact.name).read_bytes(), old_bytes)
|
||||
|
||||
def test_linux_format_release_cannot_drop_or_repoint_legacy_entry(self):
|
||||
self.make_deb()
|
||||
for key in ("linux-x86_64", "linux-x86_64-appimage"):
|
||||
payload = copy.deepcopy(self.payload)
|
||||
del payload["platforms"][key]
|
||||
with self.subTest(key=key), self.assertRaisesRegex(publisher.InvalidRelease, "identical legacy"):
|
||||
self.publish(payload)
|
||||
payload = copy.deepcopy(self.payload)
|
||||
payload["platforms"]["linux-x86_64-appimage"]["url"] = publisher.ORIGIN + "0.1.3/other.AppImage"
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "identical legacy"):
|
||||
self.publish(payload)
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_deb_identity_must_match_application_signed_version_and_architecture(self):
|
||||
for changes in ({"package": "another-launcher"}, {"version": "9.0.0"}, {"architecture": "arm64"}):
|
||||
with self.subTest(changes=changes):
|
||||
self.make_deb(**changes)
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "deb identity"):
|
||||
self.publish()
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_signed_invalid_deb_is_rejected_without_running_package_scripts(self):
|
||||
deb = self.make_deb()
|
||||
deb.write_bytes(b"not a Debian archive")
|
||||
self.payload["platforms"]["linux-x86_64-deb"]["signature"] = self.sign(deb)
|
||||
with self.assertRaisesRegex(publisher.InvalidRelease, "package inspection failed"):
|
||||
self.publish()
|
||||
self.assertFalse((self.root / "script-executed").exists())
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
def test_signed_wrong_appimage_format_is_rejected(self):
|
||||
for changed_slice, replacement in ((slice(8, 11), b"AI\x01"), (slice(18, 20), b"\xb7\x00")):
|
||||
malformed = bytearray(appimage_fixture())
|
||||
malformed[changed_slice] = replacement
|
||||
self.artifact.write_bytes(malformed)
|
||||
self.payload["platforms"]["linux-x86_64"]["signature"] = self.sign(self.artifact)
|
||||
with self.subTest(replacement=replacement), self.assertRaisesRegex(publisher.InvalidRelease, "type-2 x86_64"):
|
||||
self.publish()
|
||||
self.assertFalse(self.output.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Generated
+2
-1
@@ -3361,12 +3361,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shacraft-launcher"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
"md-5",
|
||||
"minisign-verify",
|
||||
"reqwest 0.12.28",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shacraft-launcher"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
description = "ShaCraft Minecraft launcher"
|
||||
authors = ["ShaCraft"]
|
||||
license = "MIT"
|
||||
@@ -23,6 +23,8 @@ base64 = "0.22"
|
||||
ed25519-dalek = { version = "2", features = ["pkcs8"] }
|
||||
getrandom = "0.3"
|
||||
zeroize = "1"
|
||||
libc = "0.2"
|
||||
tempfile = "3"
|
||||
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 }
|
||||
@@ -36,4 +38,3 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2", features = ["test"] }
|
||||
tempfile = "3"
|
||||
|
||||
@@ -37,6 +37,7 @@ pub(crate) async fn check_launcher_update(
|
||||
updater::trusted_builder(&app)?,
|
||||
&key,
|
||||
&app.package_info().version.to_string(),
|
||||
updater::installation_kind(&app),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -190,5 +191,13 @@ pub(crate) fn restart_launcher_after_update(
|
||||
return Err("Сначала установите обновление лаунчера.".into());
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if updater::installation_kind(&app) == updater::InstallationKind::Deb
|
||||
|| crate::deb_updater::is_deleted_installed_binary()
|
||||
{
|
||||
crate::deb_updater::restart()?;
|
||||
app.exit(0);
|
||||
return Ok(());
|
||||
}
|
||||
app.restart()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
//! The only elevated updater operation. pkexec starts this installed, root-owned
|
||||
//! binary in an early non-GUI mode. Input is untrusted until BOTH signatures
|
||||
//! are verified again here. No user-supplied path, command or password is used.
|
||||
use crate::updater::{self, InstallationKind, MAX_ARTIFACT_BYTES, MAX_METADATA_BYTES};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, Read, Write},
|
||||
os::unix::{
|
||||
fs::{MetadataExt, PermissionsExt},
|
||||
process::CommandExt,
|
||||
},
|
||||
path::Path,
|
||||
process::{Command, ExitStatus, Stdio},
|
||||
sync::mpsc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tauri_plugin_updater::Update;
|
||||
use url::Url;
|
||||
|
||||
const BINARY: &str = "/usr/bin/shacraft-launcher";
|
||||
const HELPER_FLAG: &str = "--shacraft-install-deb";
|
||||
const PACKAGE: &str = "sha-craft-launcher";
|
||||
const PKEXEC: &str = "/usr/bin/pkexec";
|
||||
const DPKG: &str = "/usr/bin/dpkg";
|
||||
const QUERY: &str = "/usr/bin/dpkg-query";
|
||||
const DEB: &str = "/usr/bin/dpkg-deb";
|
||||
const OUTPUT_LIMIT: usize = 16 * 1024;
|
||||
const INPUT_MAGIC: &[u8; 8] = b"SCDUPD01";
|
||||
const REJECTED: i32 = 20;
|
||||
const LOCKED: i32 = 21;
|
||||
const INSTALL_FAILED: i32 = 22;
|
||||
const INVALID_HOST: i32 = 23;
|
||||
|
||||
fn architecture() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
_ => "unsupported",
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the full path without following symlinks. The executable and every
|
||||
/// parent must be root-owned and not writable by group/other users.
|
||||
fn trusted_root_path(path: &Path, executable: bool) -> bool {
|
||||
if !path.is_absolute()
|
||||
|| path
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut leaf = true;
|
||||
for item in path.ancestors() {
|
||||
let Ok(meta) = fs::symlink_metadata(item) else {
|
||||
return false;
|
||||
};
|
||||
if meta.file_type().is_symlink() || meta.uid() != 0 || meta.mode() & 0o022 != 0 {
|
||||
return false;
|
||||
}
|
||||
if leaf && executable {
|
||||
if !meta.is_file() || meta.mode() & 0o111 == 0 {
|
||||
return false;
|
||||
}
|
||||
} else if !meta.is_dir() {
|
||||
return false;
|
||||
}
|
||||
leaf = false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn safe_sticky_temporary_parent(path: &Path) -> bool {
|
||||
fs::symlink_metadata(path).is_ok_and(|meta| {
|
||||
meta.is_dir()
|
||||
&& !meta.file_type().is_symlink()
|
||||
&& meta.uid() == 0
|
||||
&& (meta.mode() & 0o022 == 0 || meta.mode() & 0o1000 != 0)
|
||||
})
|
||||
}
|
||||
|
||||
fn fixed_command(path: &str) -> Command {
|
||||
let mut command = Command::new(path);
|
||||
command
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin")
|
||||
.env("LC_ALL", "C");
|
||||
command
|
||||
}
|
||||
|
||||
fn drain_capped(mut source: impl Read) -> io::Result<Vec<u8>> {
|
||||
let mut result = Vec::new();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
loop {
|
||||
let read = source.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
return Ok(result);
|
||||
}
|
||||
let remaining = OUTPUT_LIMIT.saturating_sub(result.len());
|
||||
result.extend_from_slice(&buffer[..read.min(remaining)]);
|
||||
}
|
||||
}
|
||||
|
||||
struct Captured {
|
||||
status: ExitStatus,
|
||||
stdout: Vec<u8>,
|
||||
stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Drain both pipes concurrently; output can never make the root helper buffer
|
||||
/// unbounded data or deadlock dpkg while it is changing the package database.
|
||||
fn capture(command: &mut Command) -> io::Result<Captured> {
|
||||
capture_with_deadline(command, Duration::from_secs(15))
|
||||
}
|
||||
|
||||
fn capture_with_deadline(command: &mut Command, deadline: Duration) -> io::Result<Captured> {
|
||||
// Read-only inspection has a deadline. Never kill dpkg during mutation:
|
||||
// interrupting it could leave a partially configured installed package.
|
||||
let inspection = command.get_program() != DPKG;
|
||||
if inspection {
|
||||
command.process_group(0);
|
||||
}
|
||||
let mut child = command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
let stderr = child.stderr.take().expect("piped stderr");
|
||||
let (out_send, out_receive) = mpsc::channel();
|
||||
let (err_send, err_receive) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = out_send.send(drain_capped(stdout));
|
||||
});
|
||||
std::thread::spawn(move || {
|
||||
let _ = err_send.send(drain_capped(stderr));
|
||||
});
|
||||
let start = Instant::now();
|
||||
let mut stdout = None;
|
||||
let mut stderr = None;
|
||||
loop {
|
||||
if stdout.is_none() {
|
||||
stdout = out_receive.try_recv().ok();
|
||||
}
|
||||
if stderr.is_none() {
|
||||
stderr = err_receive.try_recv().ok();
|
||||
}
|
||||
// Do not reap the parent before its pipes close. Its unreaped PID
|
||||
// reserves the process-group id until a possible timeout kill below.
|
||||
if stdout.is_some() && stderr.is_some() {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
return Ok(Captured {
|
||||
status,
|
||||
stdout: stdout.unwrap()?,
|
||||
stderr: stderr.unwrap()?,
|
||||
});
|
||||
}
|
||||
}
|
||||
if inspection && start.elapsed() > deadline {
|
||||
// Also terminate dpkg-deb's decompressor descendants so they cannot
|
||||
// retain the pipes after the inspection parent has been killed.
|
||||
unsafe {
|
||||
libc::kill(-(child.id() as i32), libc::SIGKILL);
|
||||
}
|
||||
let _ = child.wait();
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"package inspection timed out",
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_version() -> Result<String, ()> {
|
||||
let result = capture(fixed_command(QUERY).args([
|
||||
"--show",
|
||||
"--showformat=${db:Status-Status}\n${Version}\n${Architecture}\n",
|
||||
PACKAGE,
|
||||
]))
|
||||
.map_err(|_| ())?;
|
||||
if !result.status.success() {
|
||||
return Err(());
|
||||
}
|
||||
let fields = std::str::from_utf8(&result.stdout)
|
||||
.map_err(|_| ())?
|
||||
.lines()
|
||||
.collect::<Vec<_>>();
|
||||
if fields.len() != 3 || fields[0] != "installed" || fields[2] != architecture() {
|
||||
return Err(());
|
||||
}
|
||||
let version = semver::Version::parse(fields[1]).map_err(|_| ())?;
|
||||
if version.to_string() != fields[1] || !version.pre.is_empty() || !version.build.is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
// dpkg's database must also assign the precise executable to our package.
|
||||
let owner = capture(fixed_command(QUERY).args(["--search", BINARY])).map_err(|_| ())?;
|
||||
if !owner.status.success() || owner.stdout != format!("{PACKAGE}: {BINARY}\n").as_bytes() {
|
||||
return Err(());
|
||||
}
|
||||
Ok(fields[1].to_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn installed_binary_supported() -> bool {
|
||||
std::env::current_exe().is_ok_and(|path| path == Path::new(BINARY))
|
||||
&& trusted_root_path(Path::new(BINARY), true)
|
||||
&& [QUERY, DEB, DPKG]
|
||||
.iter()
|
||||
.all(|path| trusted_root_path(Path::new(path), true))
|
||||
&& installed_version().is_ok()
|
||||
}
|
||||
|
||||
pub(crate) fn unsupported_reason() -> Option<String> {
|
||||
if trusted_root_path(Path::new(PKEXEC), true) {
|
||||
None
|
||||
} else {
|
||||
Some("Для обновления deb нужен системный компонент pkexec (PolicyKit). Установите его или скачайте новый deb с shacraft.ru/help#launcher.".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_deleted_installed_binary() -> bool {
|
||||
std::env::current_exe()
|
||||
.is_ok_and(|path| path == Path::new("/usr/bin/shacraft-launcher (deleted)"))
|
||||
}
|
||||
|
||||
pub(crate) fn restart() -> Result<(), String> {
|
||||
if !trusted_root_path(Path::new(BINARY), true) {
|
||||
return Err("Установленный лаунчер недоступен. Запустите его из меню приложений.".into());
|
||||
}
|
||||
Command::new(BINARY).spawn().map_err(|_| {
|
||||
"Не удалось перезапустить лаунчер. Запустите его из меню приложений.".to_string()
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_input(mut output: impl Write, metadata: &[u8], bytes: &[u8]) -> io::Result<()> {
|
||||
if metadata.len() > MAX_METADATA_BYTES || bytes.len() > MAX_ARTIFACT_BYTES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"update exceeds input limit",
|
||||
));
|
||||
}
|
||||
output.write_all(INPUT_MAGIC)?;
|
||||
output.write_all(&(metadata.len() as u64).to_be_bytes())?;
|
||||
output.write_all(metadata)?;
|
||||
output.write_all(&(bytes.len() as u64).to_be_bytes())?;
|
||||
output.write_all(bytes)
|
||||
}
|
||||
|
||||
fn read_part(input: &mut impl Read, maximum: usize) -> io::Result<Vec<u8>> {
|
||||
let mut length = [0_u8; 8];
|
||||
input.read_exact(&mut length)?;
|
||||
let length = u64::from_be_bytes(length);
|
||||
if length == 0 || length > maximum as u64 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"invalid update input length",
|
||||
));
|
||||
}
|
||||
let mut bytes = vec![0; length as usize];
|
||||
input.read_exact(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn read_input(mut input: impl Read) -> io::Result<(Value, Vec<u8>)> {
|
||||
let mut magic = [0_u8; 8];
|
||||
input.read_exact(&mut magic)?;
|
||||
if &magic != INPUT_MAGIC {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"invalid protocol",
|
||||
));
|
||||
}
|
||||
let metadata = read_part(&mut input, MAX_METADATA_BYTES)?;
|
||||
let bytes = read_part(&mut input, MAX_ARTIFACT_BYTES)?;
|
||||
let mut trailing = [0_u8];
|
||||
if input.read(&mut trailing)? != 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "trailing input"));
|
||||
}
|
||||
let raw = serde_json::from_slice(&metadata)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid metadata"))?;
|
||||
Ok((raw, bytes))
|
||||
}
|
||||
|
||||
fn exit_message(code: Option<i32>) -> String {
|
||||
match code {
|
||||
Some(0) => "",
|
||||
Some(126) => "Установка отменена в системном окне. Текущая версия лаунчера сохранена.",
|
||||
Some(127) => "Система не разрешила установку. Подтвердите права администратора в системном окне; при его отсутствии проверьте PolicyKit.",
|
||||
Some(REJECTED) => "Системная проверка подписи или версии deb не пройдена. Установка отменена.",
|
||||
Some(LOCKED) => "Пакетный менеджер занят другой установкой. Дождитесь её завершения и нажмите «Обновить» ещё раз.",
|
||||
Some(INVALID_HOST) => "Системная установка ShaCraft не подтверждена. Установите новый deb вручную с shacraft.ru/help#launcher.",
|
||||
_ => "Пакетный менеджер не завершил установку. Проверьте состояние пакетов в системе и повторите попытку; при необходимости установите deb вручную.",
|
||||
}.to_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn install(update: &Update, bytes: &[u8]) -> Result<(), String> {
|
||||
if !installed_binary_supported() {
|
||||
return Err(exit_message(Some(INVALID_HOST)));
|
||||
}
|
||||
if let Some(reason) = unsupported_reason() {
|
||||
return Err(reason);
|
||||
}
|
||||
let metadata =
|
||||
serde_json::to_vec(&update.raw_json).map_err(|_| exit_message(Some(REJECTED)))?;
|
||||
let mut child = Command::new(PKEXEC)
|
||||
.args(["--disable-internal-agent", BINARY, HELPER_FLAG])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|_| exit_message(Some(127)))?;
|
||||
// Always wait even on EPIPE: declining the system dialog closes stdin, and
|
||||
// its exit status is the useful cancellation result, not "broken pipe".
|
||||
let write_result = write_input(
|
||||
child.stdin.take().expect("piped helper input"),
|
||||
&metadata,
|
||||
bytes,
|
||||
);
|
||||
let status = child.wait().map_err(|_| exit_message(None))?;
|
||||
if status.success() && write_result.is_ok() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(exit_message(status.code().filter(|code| *code != 0)))
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_deb_release(raw: &Value, bytes: &[u8], key: &str, installed: &str) -> Result<String, ()> {
|
||||
let metadata = updater::verified_metadata(raw, key).map_err(|_| ())?;
|
||||
if !updater::newer_version(&metadata, installed).map_err(|_| ())? {
|
||||
return Err(());
|
||||
}
|
||||
let version = metadata["version"].as_str().ok_or(())?;
|
||||
let target = format!("linux-{}-deb", std::env::consts::ARCH);
|
||||
let artifact = metadata["platforms"].get(&target).ok_or(())?;
|
||||
let url = Url::parse(artifact["url"].as_str().ok_or(())?).map_err(|_| ())?;
|
||||
updater::validate_download_url(&url, version, InstallationKind::Deb).map_err(|_| ())?;
|
||||
updater::verify_signature(bytes, artifact["signature"].as_str().ok_or(())?, key)
|
||||
.map_err(|_| ())?;
|
||||
Ok(version.to_owned())
|
||||
}
|
||||
|
||||
fn valid_package_fields(output: &[u8], version: &str) -> bool {
|
||||
std::str::from_utf8(output)
|
||||
.is_ok_and(|text| text == format!("{PACKAGE}\n{version}\n{}\n", architecture()))
|
||||
}
|
||||
|
||||
fn lock_error(stderr: &[u8]) -> bool {
|
||||
let text = String::from_utf8_lossy(stderr).to_ascii_lowercase();
|
||||
(text.contains("lock")
|
||||
&& (text.contains("locked")
|
||||
|| text.contains("another process")
|
||||
|| text.contains("resource temporarily unavailable")
|
||||
|| text.contains("unable to acquire")))
|
||||
|| text.contains("dpkg frontend lock was locked")
|
||||
}
|
||||
|
||||
fn embedded_key() -> Result<String, ()> {
|
||||
let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")).map_err(|_| ())?;
|
||||
config["plugins"]["updater"]["pubkey"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.ok_or(())
|
||||
}
|
||||
|
||||
fn run_helper() -> Result<(), i32> {
|
||||
// pkexec cleans the environment before executing this root-owned program.
|
||||
// Never initialize Tauri/GTK or network/account code in privileged mode.
|
||||
if unsafe { libc::geteuid() } != 0 || !installed_binary_supported() {
|
||||
return Err(INVALID_HOST);
|
||||
}
|
||||
let installed = installed_version().map_err(|_| INVALID_HOST)?;
|
||||
let (raw, bytes) = read_input(io::stdin().lock()).map_err(|_| REJECTED)?;
|
||||
let version = verify_deb_release(
|
||||
&raw,
|
||||
&bytes,
|
||||
&embedded_key().map_err(|_| REJECTED)?,
|
||||
&installed,
|
||||
)
|
||||
.map_err(|_| REJECTED)?;
|
||||
// No untrusted filesystem object crosses the privilege boundary. This
|
||||
// directory is created by root, mode 0700, after all signature checks.
|
||||
if !trusted_root_path(Path::new("/var"), false)
|
||||
|| !safe_sticky_temporary_parent(Path::new("/var/tmp"))
|
||||
{
|
||||
return Err(INVALID_HOST);
|
||||
}
|
||||
let temp = tempfile::Builder::new()
|
||||
.prefix("shacraft-update-")
|
||||
.tempdir_in("/var/tmp")
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700))
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
let package = temp.path().join("release.deb");
|
||||
let mut output = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&package)
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
output
|
||||
.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
output
|
||||
.write_all(&bytes)
|
||||
.and_then(|_| output.sync_all())
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
drop(output);
|
||||
let fields = capture(
|
||||
fixed_command(DEB)
|
||||
.arg("--show")
|
||||
.arg("--showformat=${Package}\n${Version}\n${Architecture}\n")
|
||||
.arg(&package),
|
||||
)
|
||||
.map_err(|_| REJECTED)?;
|
||||
if !fields.status.success() || !valid_package_fields(&fields.stdout, &version) {
|
||||
return Err(REJECTED);
|
||||
}
|
||||
// Check again immediately before mutation: another updater might have
|
||||
// installed the release while the authentication dialog was open.
|
||||
if !updater::newer_version(
|
||||
&serde_json::json!({"version": version}),
|
||||
&installed_version().map_err(|_| INVALID_HOST)?,
|
||||
)
|
||||
.map_err(|_| REJECTED)?
|
||||
{
|
||||
return Err(REJECTED);
|
||||
}
|
||||
let result = capture(
|
||||
fixed_command(DPKG)
|
||||
.args(["--refuse-downgrade", "--install"])
|
||||
.arg(&package),
|
||||
)
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
if !result.status.success() {
|
||||
return Err(if lock_error(&result.stderr) {
|
||||
LOCKED
|
||||
} else {
|
||||
INSTALL_FAILED
|
||||
});
|
||||
}
|
||||
if installed_version().map_err(|_| INSTALL_FAILED)? != version {
|
||||
return Err(INSTALL_FAILED);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The special flag is never registered as IPC and does not accept filenames.
|
||||
/// Even manually invoking it cannot bypass signatures, package identity or
|
||||
/// privilege checks. Errors intentionally print no package/metadata contents.
|
||||
pub(crate) fn run_helper_if_requested() -> Option<i32> {
|
||||
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
if !arguments.iter().any(|argument| argument == HELPER_FLAG) {
|
||||
return None;
|
||||
}
|
||||
if arguments.len() != 1 || arguments[0] != HELPER_FLAG {
|
||||
return Some(REJECTED);
|
||||
}
|
||||
Some(match run_helper() {
|
||||
Ok(()) => 0,
|
||||
Err(code) => code,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn framed_input_rejects_oversized_truncated_and_trailing_data() {
|
||||
let mut bytes = Vec::new();
|
||||
write_input(&mut bytes, b"{}", b"package").unwrap();
|
||||
let (metadata, package) = read_input(&bytes[..]).unwrap();
|
||||
assert_eq!(metadata, serde_json::json!({}));
|
||||
assert_eq!(package, b"package");
|
||||
assert!(read_input(&bytes[..bytes.len() - 1]).is_err());
|
||||
bytes.push(0);
|
||||
assert!(read_input(&bytes[..]).is_err());
|
||||
let mut oversized = INPUT_MAGIC.to_vec();
|
||||
oversized.extend_from_slice(&u64::MAX.to_be_bytes());
|
||||
assert!(read_input(&oversized[..]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_identity_version_architecture_are_exact() {
|
||||
let good = format!("{PACKAGE}\n0.1.4\n{}\n", architecture());
|
||||
assert!(valid_package_fields(good.as_bytes(), "0.1.4"));
|
||||
for wrong in [
|
||||
good.replace(PACKAGE, "another-package"),
|
||||
good.replace("0.1.4", "0.1.5"),
|
||||
good.replace(architecture(), "all"),
|
||||
format!("{good}extra\n"),
|
||||
] {
|
||||
assert!(!valid_package_fields(wrong.as_bytes(), "0.1.4"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_authorization_and_package_lock_remain_distinct() {
|
||||
assert!(exit_message(Some(126)).contains("отменена"));
|
||||
assert!(exit_message(Some(127)).contains("не разрешила"));
|
||||
assert!(exit_message(Some(LOCKED)).contains("занят"));
|
||||
assert!(lock_error(
|
||||
b"dpkg: error: dpkg frontend lock was locked by another process"
|
||||
));
|
||||
assert!(!lock_error(
|
||||
b"dpkg: dependency problems prevent configuration"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privileged_path_rejects_user_owned_files_symlinks_and_relative_paths() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let file = directory.path().join("launcher");
|
||||
fs::write(&file, b"file").unwrap();
|
||||
fs::set_permissions(&file, fs::Permissions::from_mode(0o777)).unwrap();
|
||||
assert!(!trusted_root_path(&file, true));
|
||||
let link = directory.path().join("link");
|
||||
std::os::unix::fs::symlink("/usr/bin/dpkg", &link).unwrap();
|
||||
assert!(!trusted_root_path(&link, true));
|
||||
assert!(!trusted_root_path(Path::new("usr/bin/dpkg"), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_verification_does_not_accept_legacy_appimage_as_deb() {
|
||||
let fixture: Value =
|
||||
serde_json::from_str(include_str!("../tests/fixtures/updater-signed.json")).unwrap();
|
||||
assert!(verify_deb_release(
|
||||
&fixture["metadata"],
|
||||
fixture["artifactText"].as_str().unwrap().as_bytes(),
|
||||
fixture["publicKey"].as_str().unwrap(),
|
||||
"0.0.0"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspection_timeout_kills_descendants_holding_output_pipes() {
|
||||
let start = Instant::now();
|
||||
let result = capture_with_deadline(
|
||||
Command::new("/bin/sh").args(["-c", "sleep 30 & exit 0"]),
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
assert!(matches!(result, Err(error) if error.kind() == io::ErrorKind::TimedOut));
|
||||
assert!(start.elapsed() < Duration::from_secs(3));
|
||||
let output = capture(fixed_command(DEB).arg("--version")).unwrap();
|
||||
assert!(output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("Debian"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_output_is_bounded_and_fully_drained() {
|
||||
let input = vec![b'x'; OUTPUT_LIMIT * 4];
|
||||
assert_eq!(drain_capped(input.as_slice()).unwrap().len(), OUTPUT_LIMIT);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
mod admission;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod deb_updater;
|
||||
mod download;
|
||||
mod java;
|
||||
mod launch;
|
||||
@@ -20,6 +22,10 @@ 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())
|
||||
|
||||
+160
-34
@@ -15,13 +15,21 @@ use tauri_plugin_updater::{Update, UpdaterBuilder, UpdaterExt};
|
||||
use url::Url;
|
||||
|
||||
pub(crate) const UPDATE_ENDPOINT: &str = "https://shacraft.ru/launcher/updates/stable.json";
|
||||
const MAX_METADATA_BYTES: usize = 192 * 1024;
|
||||
pub(crate) const MAX_METADATA_BYTES: usize = 192 * 1024;
|
||||
const MAX_PAYLOAD_BYTES: usize = 64 * 1024;
|
||||
const MAX_ARTIFACT_BYTES: usize = 256 * 1024 * 1024;
|
||||
pub(crate) const MAX_ARTIFACT_BYTES: usize = 256 * 1024 * 1024;
|
||||
const BAD_METADATA: &str =
|
||||
"Не удалось подтвердить подлинность сведений об обновлении. Повторите проверку позже.";
|
||||
const BAD_SIGNATURE: &str = "Подпись обновления не прошла проверку. Установка отменена.";
|
||||
|
||||
#[derive(Clone, Copy, Serialize, PartialEq, Eq, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum InstallationKind {
|
||||
Appimage,
|
||||
Deb,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum Stage {
|
||||
@@ -53,6 +61,7 @@ pub(crate) struct LauncherUpdater {
|
||||
pub(crate) struct UpdateStatus {
|
||||
pub current_version: String,
|
||||
pub supported: bool,
|
||||
pub installation_kind: InstallationKind,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
pub stage: Stage,
|
||||
@@ -81,6 +90,7 @@ impl LauncherUpdater {
|
||||
Ok(UpdateStatus {
|
||||
current_version: app.package_info().version.to_string(),
|
||||
supported: reason.is_none(),
|
||||
installation_kind: installation_kind(app),
|
||||
reason,
|
||||
stage: state.stage,
|
||||
version: state
|
||||
@@ -103,28 +113,39 @@ impl LauncherUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn unsupported_reason<R: Runtime>(app: &AppHandle<R>) -> Option<String> {
|
||||
pub(crate) fn installation_kind<R: Runtime>(app: &AppHandle<R>) -> InstallationKind {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let env = app.env();
|
||||
let valid = match (
|
||||
if let (Some(image), Some(directory), Ok(executable)) = (
|
||||
env.appimage.as_ref(),
|
||||
env.appdir.as_ref(),
|
||||
std::env::current_exe(),
|
||||
) {
|
||||
(Some(image), Some(directory), Ok(executable)) => linux_appimage_supported(
|
||||
std::path::Path::new(image),
|
||||
std::path::Path::new(directory),
|
||||
&executable,
|
||||
),
|
||||
_ => false,
|
||||
};
|
||||
if !valid {
|
||||
return Some("Автообновление в Linux доступно в AppImage. Установите AppImage с shacraft.ru и запускайте его.".into());
|
||||
if linux_appimage_supported(image.as_ref(), directory.as_ref(), &executable) {
|
||||
return InstallationKind::Appimage;
|
||||
}
|
||||
}
|
||||
if crate::deb_updater::installed_binary_supported() {
|
||||
return InstallationKind::Deb;
|
||||
}
|
||||
}
|
||||
let _ = app;
|
||||
InstallationKind::Other
|
||||
}
|
||||
|
||||
pub(crate) fn unsupported_reason<R: Runtime>(app: &AppHandle<R>) -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
match installation_kind(app) {
|
||||
InstallationKind::Appimage => return None,
|
||||
InstallationKind::Deb => return crate::deb_updater::unsupported_reason(),
|
||||
InstallationKind::Other => return Some("Для автообновления установите deb-пакет или запустите AppImage с shacraft.ru/help#launcher.".into()),
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = app;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
|
||||
return Some("Для этой платформы доступна только ручная установка обновлений.".into());
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
None
|
||||
}
|
||||
|
||||
@@ -169,6 +190,9 @@ pub(crate) fn installation_path<R: Runtime>(
|
||||
) -> Result<Option<std::path::PathBuf>, String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if installation_kind(app) == InstallationKind::Deb {
|
||||
return Ok(None);
|
||||
}
|
||||
let image = app
|
||||
.env()
|
||||
.appimage
|
||||
@@ -250,7 +274,7 @@ async fn bounded_response(
|
||||
/// Same Minisign format and verification semantics as Tauri's updater. The
|
||||
/// signed metadata and artifact each need a valid signature under the embedded
|
||||
/// release key. A signed old artifact cannot be labelled as a new version.
|
||||
fn verify_signature(
|
||||
pub(crate) fn verify_signature(
|
||||
bytes: &[u8],
|
||||
encoded_signature: &str,
|
||||
encoded_key: &str,
|
||||
@@ -271,7 +295,7 @@ fn verify_signature(
|
||||
.map_err(|_| BAD_SIGNATURE.into())
|
||||
}
|
||||
|
||||
fn verified_metadata(raw: &Value, key: &str) -> Result<Value, String> {
|
||||
pub(crate) fn verified_metadata(raw: &Value, key: &str) -> Result<Value, String> {
|
||||
let object = raw.as_object().ok_or(BAD_METADATA)?;
|
||||
if object.len() != 6 {
|
||||
return Err(BAD_METADATA.into());
|
||||
@@ -305,7 +329,7 @@ fn verified_metadata(raw: &Value, key: &str) -> Result<Value, String> {
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn newer_version(metadata: &Value, current: &str) -> Result<bool, String> {
|
||||
pub(crate) fn newer_version(metadata: &Value, current: &str) -> Result<bool, String> {
|
||||
let announced = metadata
|
||||
.get("version")
|
||||
.and_then(Value::as_str)
|
||||
@@ -319,7 +343,7 @@ fn newer_version(metadata: &Value, current: &str) -> Result<bool, String> {
|
||||
Ok(version > current)
|
||||
}
|
||||
|
||||
fn require_platform(metadata: &Value) -> Result<(), String> {
|
||||
fn require_platform(metadata: &Value, kind: InstallationKind) -> Result<String, String> {
|
||||
let os = if cfg!(target_os = "macos") {
|
||||
"darwin"
|
||||
} else {
|
||||
@@ -330,17 +354,34 @@ fn require_platform(metadata: &Value) -> Result<(), String> {
|
||||
.get("platforms")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or(BAD_METADATA)?;
|
||||
let available = platforms.contains_key(&target)
|
||||
|| ["appimage", "nsis", "msi", "app"]
|
||||
#[cfg(target_os = "linux")]
|
||||
let targets = match kind {
|
||||
InstallationKind::Deb => vec![format!("{target}-deb")],
|
||||
InstallationKind::Appimage => vec![format!("{target}-appimage"), target],
|
||||
InstallationKind::Other => {
|
||||
return Err("Формат установленного лаунчера не поддерживает обновление.".into())
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let targets = {
|
||||
let _ = kind;
|
||||
["nsis", "msi", "app"]
|
||||
.iter()
|
||||
.any(|bundle| platforms.contains_key(&format!("{target}-{bundle}")));
|
||||
if !available {
|
||||
return Err("Обновление для вашей платформы пока не опубликовано.".into());
|
||||
}
|
||||
Ok(())
|
||||
.map(|bundle| format!("{target}-{bundle}"))
|
||||
.chain(std::iter::once(target))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
targets
|
||||
.into_iter()
|
||||
.find(|target| platforms.contains_key(target))
|
||||
.ok_or_else(|| "Обновление для вашего формата установки пока не опубликовано.".into())
|
||||
}
|
||||
|
||||
fn validate_download_url(url: &Url, version: &str) -> Result<(), String> {
|
||||
pub(crate) fn validate_download_url(
|
||||
url: &Url,
|
||||
version: &str,
|
||||
kind: InstallationKind,
|
||||
) -> Result<(), String> {
|
||||
let prefix = format!("/downloads/shacraft-launcher/{version}/");
|
||||
let filename = url.path().strip_prefix(&prefix).ok_or(BAD_METADATA)?;
|
||||
if url.scheme() != "https"
|
||||
@@ -359,7 +400,11 @@ fn validate_download_url(url: &Url, version: &str) -> Result<(), String> {
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
let correct_extension = if cfg!(target_os = "linux") {
|
||||
filename.ends_with(".AppImage")
|
||||
match kind {
|
||||
InstallationKind::Appimage => filename.ends_with(".AppImage"),
|
||||
InstallationKind::Deb => filename.ends_with(".deb"),
|
||||
InstallationKind::Other => false,
|
||||
}
|
||||
} else if cfg!(target_os = "macos") {
|
||||
filename.ends_with(".app.tar.gz")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
@@ -373,6 +418,18 @@ fn validate_download_url(url: &Url, version: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn candidate_kind(update: &Update) -> InstallationKind {
|
||||
if cfg!(target_os = "linux") {
|
||||
if update.target == format!("linux-{}-deb", std::env::consts::ARCH) {
|
||||
InstallationKind::Deb
|
||||
} else {
|
||||
InstallationKind::Appimage
|
||||
}
|
||||
} else {
|
||||
InstallationKind::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch and authenticate a bounded static manifest before asking the vendored
|
||||
/// upstream plugin's small offline constructor to create an Update. Its normal
|
||||
/// HTTP check is intentionally unused because it buffers unbounded JSON.
|
||||
@@ -380,6 +437,7 @@ pub(crate) async fn check_candidate(
|
||||
builder: UpdaterBuilder,
|
||||
key: &str,
|
||||
current: &str,
|
||||
kind: InstallationKind,
|
||||
) -> Result<Option<Update>, String> {
|
||||
let response = http_client(Duration::from_secs(20))?
|
||||
.get(UPDATE_ENDPOINT)
|
||||
@@ -391,10 +449,14 @@ pub(crate) async fn check_candidate(
|
||||
let bytes = bounded_response(response, MAX_METADATA_BYTES, |_, _| {}).await?;
|
||||
let raw: Value = serde_json::from_slice(&bytes).map_err(|_| BAD_METADATA)?;
|
||||
let metadata = verified_metadata(&raw, key)?;
|
||||
require_platform(&metadata)?;
|
||||
let target = require_platform(&metadata, kind)?;
|
||||
if !newer_version(&metadata, current)? {
|
||||
return Ok(None);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
let builder = builder.target(target);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = target;
|
||||
let update = builder
|
||||
.build()
|
||||
.map_err(updater_error)?
|
||||
@@ -409,7 +471,11 @@ pub(crate) async fn check_candidate(
|
||||
}
|
||||
// Retain the exact signed envelope with the native-only candidate.
|
||||
verified_metadata(&update.raw_json, key)?;
|
||||
validate_download_url(&update.download_url, &update.version)?;
|
||||
validate_download_url(
|
||||
&update.download_url,
|
||||
&update.version,
|
||||
candidate_kind(&update),
|
||||
)?;
|
||||
Ok(Some(update))
|
||||
}
|
||||
|
||||
@@ -418,7 +484,11 @@ pub(crate) async fn download_verified(
|
||||
key: &str,
|
||||
progress: impl FnMut(u64, Option<u64>),
|
||||
) -> Result<Vec<u8>, String> {
|
||||
validate_download_url(&update.download_url, &update.version)?;
|
||||
validate_download_url(
|
||||
&update.download_url,
|
||||
&update.version,
|
||||
candidate_kind(update),
|
||||
)?;
|
||||
verified_metadata(&update.raw_json, key)?;
|
||||
let response = http_client(Duration::from_secs(600))?
|
||||
.get(update.download_url.clone())
|
||||
@@ -441,10 +511,17 @@ pub(crate) fn install_verified(
|
||||
key: &str,
|
||||
destination: Option<&std::path::Path>,
|
||||
) -> Result<(), String> {
|
||||
validate_download_url(&update.download_url, &update.version)?;
|
||||
validate_download_url(
|
||||
&update.download_url,
|
||||
&update.version,
|
||||
candidate_kind(update),
|
||||
)?;
|
||||
verify_signature(bytes, &update.signature, key)?;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if candidate_kind(update) == InstallationKind::Deb {
|
||||
return crate::deb_updater::install(update, bytes);
|
||||
}
|
||||
let destination = destination.ok_or("Файл AppImage недоступен.")?;
|
||||
install_appimage_atomic(destination, bytes).map_err(|_| {
|
||||
"Не удалось заменить AppImage. Проверьте свободное место и права на папку лаунчера."
|
||||
@@ -522,7 +599,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn download_policy_pins_origin_version_plain_path_and_package_type() {
|
||||
assert!(validate_download_url(&Url::parse(artifact_url()).unwrap(), "0.2.0").is_ok());
|
||||
assert!(validate_download_url(
|
||||
&Url::parse(artifact_url()).unwrap(),
|
||||
"0.2.0",
|
||||
InstallationKind::Appimage
|
||||
)
|
||||
.is_ok());
|
||||
for value in [
|
||||
artifact_url().replace("https:", "http:"),
|
||||
artifact_url().replace("shacraft.ru/", "evil.example/"),
|
||||
@@ -536,12 +618,52 @@ mod tests {
|
||||
format!("{}.sh", artifact_url()),
|
||||
] {
|
||||
assert!(
|
||||
validate_download_url(&Url::parse(&value).unwrap(), "0.2.0").is_err(),
|
||||
validate_download_url(
|
||||
&Url::parse(&value).unwrap(),
|
||||
"0.2.0",
|
||||
InstallationKind::Appimage
|
||||
)
|
||||
.is_err(),
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_selects_package_family_without_deb_fallback() {
|
||||
let base = format!("linux-{}", std::env::consts::ARCH);
|
||||
let legacy = serde_json::json!({"platforms": {base.clone(): {}}});
|
||||
assert_eq!(
|
||||
require_platform(&legacy, InstallationKind::Appimage).unwrap(),
|
||||
base
|
||||
);
|
||||
assert!(require_platform(&legacy, InstallationKind::Deb).is_err());
|
||||
let exact_image = format!("{base}-appimage");
|
||||
let exact_deb = format!("{base}-deb");
|
||||
let all = serde_json::json!({"platforms": {base.clone(): {}, exact_image.clone(): {}, exact_deb.clone(): {}}});
|
||||
assert_eq!(
|
||||
require_platform(&all, InstallationKind::Appimage).unwrap(),
|
||||
exact_image
|
||||
);
|
||||
assert_eq!(
|
||||
require_platform(&all, InstallationKind::Deb).unwrap(),
|
||||
exact_deb
|
||||
);
|
||||
let deb = Url::parse(
|
||||
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0_amd64.deb",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(validate_download_url(&deb, "0.2.0", InstallationKind::Deb).is_ok());
|
||||
assert!(validate_download_url(&deb, "0.2.0", InstallationKind::Appimage).is_err());
|
||||
assert!(validate_download_url(
|
||||
&Url::parse(artifact_url()).unwrap(),
|
||||
"0.2.0",
|
||||
InstallationKind::Deb
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_channel_never_downgrades_or_installs_equal_aliases() {
|
||||
assert!(newer_version(&serde_json::json!({"version":"0.2.0"}), "0.1.3").unwrap());
|
||||
@@ -621,7 +743,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unavailable_platform_is_not_reported_as_latest() {
|
||||
assert!(require_platform(&serde_json::json!({"platforms":{}})).is_err());
|
||||
assert!(require_platform(
|
||||
&serde_json::json!({"platforms":{}}),
|
||||
InstallationKind::Appimage
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -698,7 +824,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.executable_path(&destination);
|
||||
tauri::async_runtime::block_on(async {
|
||||
let update = check_candidate(builder, &key, "0.1.2")
|
||||
let update = check_candidate(builder, &key, "0.1.2", InstallationKind::Appimage)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("newer published version");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ShaCraft Launcher",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"identifier": "ru.shacraft.launcher",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@@ -36,7 +36,16 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": [
|
||||
"libwebkit2gtk-4.1-0",
|
||||
"libgtk-3-0",
|
||||
"pkexec"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
|
||||
@@ -10,6 +10,7 @@ export function LauncherUpdateSettings({ updater }: { updater: ReturnType<typeof
|
||||
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">
|
||||
@@ -19,20 +20,24 @@ export function LauncherUpdateSettings({ updater }: { updater: ReturnType<typeof
|
||||
<div className="launcher-update-status" aria-live="polite">
|
||||
{!isNative() ? <p>Обновления доступны в приложении лаунчера.</p>
|
||||
: ready ? <p className="update-success">Обновление установлено. Перезапустите лаунчер.</p>
|
||||
: working ? <p>{phase === 'installing' ? 'Проверяем подпись и устанавливаем…'
|
||||
: 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="Загрузка обновления лаунчера" max={100} value={phase === 'installing' ? undefined : percent ?? undefined} />}
|
||||
{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' ? 'Перезапускаем…' : 'Перезапустить лаунчер'}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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'
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
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('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('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('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, /администратора|системном окне/)
|
||||
}
|
||||
})
|
||||
@@ -84,6 +84,7 @@ export interface GameExitedPayload {
|
||||
export interface LauncherUpdateStatus {
|
||||
currentVersion: string
|
||||
supported: boolean
|
||||
installationKind?: 'appimage' | 'deb' | 'other'
|
||||
reason?: string | null
|
||||
version?: string | null
|
||||
notes?: string | null
|
||||
|
||||
Reference in New Issue
Block a user