diff --git a/AGENTS.md b/AGENTS.md index ab4c13a..9abfc20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//`, 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 diff --git a/PLAN.md b/PLAN.md index a9f678c..3a53f63 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 обязательны diff --git a/README.md b/README.md index 7f15368..38ec584 100644 --- a/README.md +++ b/README.md @@ -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 с этим механизмом ещё требуют публикации и проверки установки. ## Навигация diff --git a/docs/launcher-architecture.md b/docs/launcher-architecture.md index b626c60..80ede3f 100644 --- a/docs/launcher-architecture.md +++ b/docs/launcher-architecture.md @@ -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. diff --git a/docs/launcher-updates.md b/docs/launcher-updates.md index d86a6d3..42645be 100644 --- a/docs/launcher-updates.md +++ b/docs/launcher-updates.md @@ -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. diff --git a/package-lock.json b/package-lock.json index f15dd31..0071c80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9959d51..01b39c7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/publish_launcher_update.py b/scripts/publish_launcher_update.py index 46fd8af..f057743 100644 --- a/scripts/publish_launcher_update.py +++ b/scripts/publish_launcher_update.py @@ -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 diff --git a/scripts/test_publish_launcher_update.py b/scripts/test_publish_launcher_update.py index 21472c8..68193a7 100644 --- a/scripts/test_publish_launcher_update.py +++ b/scripts/test_publish_launcher_update.py @@ -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 \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() diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 54a9e12..c570e03 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6fa1ac0..0625e34 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/commands/updater.rs b/src-tauri/src/commands/updater.rs index e77eb71..a9cbd7b 100644 --- a/src-tauri/src/commands/updater.rs +++ b/src-tauri/src/commands/updater.rs @@ -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() } diff --git a/src-tauri/src/deb_updater.rs b/src-tauri/src/deb_updater.rs new file mode 100644 index 0000000..bf695f7 --- /dev/null +++ b/src-tauri/src/deb_updater.rs @@ -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> { + 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, + stderr: Vec, +} + +/// 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 { + capture_with_deadline(command, Duration::from_secs(15)) +} + +fn capture_with_deadline(command: &mut Command, deadline: Duration) -> io::Result { + // 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 { + 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::>(); + 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 { + 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> { + 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)> { + 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) -> 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 { + 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 { + 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 { + let arguments = std::env::args_os().skip(1).collect::>(); + 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); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f76e759..d555b39 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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()) diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index cff00e2..c650eaa 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -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, 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(app: &AppHandle) -> Option { +pub(crate) fn installation_kind(app: &AppHandle) -> 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(app: &AppHandle) -> Option { + #[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( ) -> Result, 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 { +pub(crate) fn verified_metadata(raw: &Value, key: &str) -> Result { 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 { Ok(parsed) } -fn newer_version(metadata: &Value, current: &str) -> Result { +pub(crate) fn newer_version(metadata: &Value, current: &str) -> Result { let announced = metadata .get("version") .and_then(Value::as_str) @@ -319,7 +343,7 @@ fn newer_version(metadata: &Value, current: &str) -> Result { Ok(version > current) } -fn require_platform(metadata: &Value) -> Result<(), String> { +fn require_platform(metadata: &Value, kind: InstallationKind) -> Result { 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::>() + }; + 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, 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), ) -> Result, 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"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7313a4b..6db7ca0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ShaCraft Launcher", - "version": "0.1.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": { diff --git a/src/components/LauncherUpdateSettings.tsx b/src/components/LauncherUpdateSettings.tsx index c86af45..eb31bd9 100644 --- a/src/components/LauncherUpdateSettings.tsx +++ b/src/components/LauncherUpdateSettings.tsx @@ -10,6 +10,7 @@ export function LauncherUpdateSettings({ updater }: { updater: ReturnType
@@ -19,20 +20,24 @@ export function LauncherUpdateSettings({ updater }: { updater: ReturnType {!isNative() ?

Обновления доступны в приложении лаунчера.

: ready ?

Обновление установлено. Перезапустите лаунчер.

- : working ?

{phase === 'installing' ? 'Проверяем подпись и устанавливаем…' + : working ?

{phase === 'installing' ? deb + ? 'Подтвердите установку в системном окне и дождитесь завершения.' + : 'Проверяем подпись и устанавливаем…' : `Скачиваем обновление${percent === null ? '…' : ` · ${percent}%`}`}

: checking ?

Проверяем обновления…

: status?.supported === false ?

{status.reason || 'Для этой установки обновление доступно вручную на shacraft.ru/help#launcher.'}

: status?.version ?

Доступна версия {status.version}

: state.checked && !error ?

У вас последняя версия.

:

Проверка новой версии лаунчера.

} - {working && } + {working && }
{status?.notes && status.version && !working && !ready &&
Что нового

{status.notes.slice(0, 1600)}

} {error &&

{error}

} {eventError &&

{eventError} Перезапустите лаунчер, чтобы включить установку обновлений.

} + {deb && status?.supported && status.version && !working && !ready && +

Для обновления deb потребуется подтверждение администратора в системном окне.

} {isNative() &&
{ready ?