Add signed launcher self-updates and publish Linux 0.1.3

This commit is contained in:
Emil
2026-09-10 02:47:16 +03:00
parent bf43254c57
commit bb4b1f8051
55 changed files with 6286 additions and 43 deletions
+3
View File
@@ -163,6 +163,7 @@ pub(crate) async fn launch_game(
let game_dir = data_dir(&app)?.join("game");
let data_dir = data_dir(&app)?;
let permit = state.installation.acquire("Installation")?;
let game_permit = state.game.acquire("Игра")?;
let account_operation = state.shacraft_account.clone();
tauri::async_runtime::spawn_blocking(move || -> Result<(), String> {
@@ -219,7 +220,9 @@ pub(crate) async fn launch_game(
let watch_app = app.clone();
let watch_profile_id = profile_id.clone();
std::thread::spawn(move || {
let game_permit = game_permit;
let exit_code = child.wait().ok().and_then(|status| status.code());
drop(game_permit);
let _ = watch_app.emit(
"game-exited",
GameExited {
+1
View File
@@ -5,6 +5,7 @@ pub(crate) mod host;
pub(crate) mod preferences;
pub(crate) mod profiles;
pub(crate) mod shacraft;
pub(crate) mod updater;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
+194
View File
@@ -0,0 +1,194 @@
use crate::{
operations::LauncherOperations,
updater::{self, LauncherUpdater, Stage, UpdateProgress, UpdateStatus},
};
use tauri::{AppHandle, Emitter, State};
#[tauri::command]
pub(crate) fn get_launcher_update_status(
app: AppHandle,
updater: State<'_, LauncherUpdater>,
) -> Result<UpdateStatus, String> {
updater.status(&app)
}
#[tauri::command]
pub(crate) async fn check_launcher_update(
app: AppHandle,
updater: State<'_, LauncherUpdater>,
) -> Result<UpdateStatus, String> {
let status = updater.status(&app)?;
if !status.supported || status.stage == Stage::Ready {
return Ok(status);
}
let permit = updater
.operation
.acquire("Проверка или установка обновления")
.map_err(|_| "Проверка или установка обновления уже выполняется.".to_string())?;
let updater = updater.inner().clone();
updater.set_stage(Stage::Checking)?;
// Detached task owns the permit: cancelling an IPC caller cannot release
// the operation while its native HTTP request is still in flight.
tauri::async_runtime::spawn(async move {
let _permit = permit;
let result = async {
let key = updater::public_key(&app)?;
updater::check_candidate(
updater::trusted_builder(&app)?,
&key,
&app.package_info().version.to_string(),
)
.await
}
.await;
{
let mut state = updater
.state
.lock()
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
match result {
Ok(candidate) => {
state.stage = if candidate.is_some() {
Stage::Available
} else {
Stage::Idle
};
state.candidate = candidate;
}
Err(error) => {
state.stage = if state.candidate.is_some() {
Stage::Available
} else {
Stage::Idle
};
return Err(error);
}
}
}
updater.status(&app)
})
.await
.map_err(|_| "Проверка обновления завершилась с ошибкой.".to_string())?
}
#[tauri::command]
pub(crate) async fn install_launcher_update(
app: AppHandle,
updater: State<'_, LauncherUpdater>,
operations: State<'_, LauncherOperations>,
) -> Result<(), String> {
if let Some(reason) = updater::unsupported_reason(&app) {
return Err(reason);
}
let permit = updater
.operation
.acquire("Проверка или установка обновления")
.map_err(|_| "Проверка или установка обновления уже выполняется.".to_string())?;
let candidate = {
let state = updater
.state
.lock()
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
if state.stage == Stage::Ready {
return Err("Обновление уже установлено. Перезапустите лаунчер.".into());
}
state
.candidate
.clone()
.ok_or("Сначала проверьте наличие обновлений.")?
};
let destination = updater::installation_path(&app)?;
let mutation_permits = operations.acquire_update()
.map_err(|_| "Закройте Minecraft и дождитесь завершения установки или входа в аккаунт перед обновлением.".to_string())?;
let updater = updater.inner().clone();
updater.set_stage(Stage::Downloading)?;
tauri::async_runtime::spawn(async move {
let _permit = permit;
let result = async {
let key = updater::public_key(&app)?;
let _ = app.emit(
"launcher-update-progress",
UpdateProgress {
stage: Stage::Downloading,
downloaded_bytes: 0,
total_bytes: None,
},
);
let bytes =
updater::download_verified(&candidate, &key, |downloaded_bytes, total_bytes| {
let _ = app.emit(
"launcher-update-progress",
UpdateProgress {
stage: Stage::Downloading,
downloaded_bytes,
total_bytes,
},
);
})
.await?;
let size = bytes.len() as u64;
updater.set_stage(Stage::Installing)?;
let _ = app.emit(
"launcher-update-progress",
UpdateProgress {
stage: Stage::Installing,
downloaded_bytes: size,
total_bytes: Some(size),
},
);
tauri::async_runtime::spawn_blocking(move || {
updater::install_verified(&candidate, &bytes, &key, destination.as_deref())
})
.await
.map_err(|_| "Установка обновления завершилась с ошибкой.".to_string())??;
Ok::<_, String>(size)
}
.await;
match result {
Ok(size) => {
let mut state = updater
.state
.lock()
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
state.stage = Stage::Ready;
state.restart_permits = Some(mutation_permits);
let _ = app.emit(
"launcher-update-progress",
UpdateProgress {
stage: Stage::Ready,
downloaded_bytes: size,
total_bytes: Some(size),
},
);
Ok(())
}
Err(error) => {
updater.set_stage(Stage::Available)?;
Err(error)
}
}
})
.await
.map_err(|_| "Установка обновления завершилась с ошибкой.".to_string())?
}
#[tauri::command]
pub(crate) fn restart_launcher_after_update(
app: AppHandle,
updater: State<'_, LauncherUpdater>,
) -> Result<(), String> {
let _permit = updater
.operation
.acquire("Установка обновления")
.map_err(|_| "Установка обновления ещё выполняется.".to_string())?;
{
let state = updater
.state
.lock()
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
if state.stage != Stage::Ready || state.restart_permits.is_none() {
return Err("Сначала установите обновление лаунчера.".into());
}
}
app.restart()
}
+24 -1
View File
@@ -14,13 +14,32 @@ mod settings;
mod shacraft_account;
mod storage;
mod trusted_http;
mod updater;
mod commands;
mod operations;
pub fn run() {
use tauri::Manager;
tauri::Builder::default()
.manage(operations::LauncherOperations::default())
.manage(updater::LauncherUpdater::default())
.plugin(tauri_plugin_updater::Builder::new().build())
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// Do not interrupt the short package replacement step. A
// download can safely be abandoned before any file changes.
if let Some(updater) = window.try_state::<updater::LauncherUpdater>() {
if updater
.state
.lock()
.is_ok_and(|state| state.stage == updater::Stage::Installing)
{
api.prevent_close();
}
}
}
})
.invoke_handler(tauri::generate_handler![
commands::host::native_host,
commands::host::detect_java,
@@ -41,7 +60,11 @@ pub fn run() {
commands::account::get_account,
commands::account::logout,
commands::game::ensure_game_installed,
commands::game::launch_game
commands::game::launch_game,
commands::updater::get_launcher_update_status,
commands::updater::check_launcher_update,
commands::updater::install_launcher_update,
commands::updater::restart_launcher_after_update
])
.run(tauri::generate_context!())
.expect("error while running ShaCraft Launcher");
+72 -2
View File
@@ -7,11 +7,42 @@ use std::sync::{
Arc,
};
#[derive(Default)]
#[derive(Clone, Default)]
pub(crate) struct LauncherOperations {
pub installation: Operation,
pub account: Operation,
pub shacraft_account: Operation,
// Held from launch scheduling until Child::wait completes, not just spawn.
pub game: Operation,
}
impl LauncherOperations {
/// Acquire every mutation gate without waiting. Partial acquisition is
/// rolled back by RAII, so a failed update cannot strand an account gate.
pub fn acquire_update(&self) -> Result<UpdatePermits, String> {
let installation = self.installation.acquire("Установка игры")?;
let account = self.account.acquire("Вход в аккаунт")?;
let shacraft_account = self
.shacraft_account
.acquire("Операция с аккаунтом ShaCraft")?;
let game = self
.game
.acquire("Игра")
.map_err(|_| "Закройте Minecraft перед обновлением лаунчера.".to_string())?;
Ok(UpdatePermits {
_installation: installation,
_account: account,
_shacraft_account: shacraft_account,
_game: game,
})
}
}
pub(crate) struct UpdatePermits {
_installation: Permit,
_account: Permit,
_shacraft_account: Permit,
_game: Permit,
}
#[derive(Clone, Default)]
@@ -36,7 +67,7 @@ impl Drop for Permit {
#[cfg(test)]
mod tests {
use super::Operation;
use super::{LauncherOperations, Operation};
#[test]
fn rejects_overlap_and_releases_on_worker_error() {
@@ -49,4 +80,43 @@ mod tests {
assert!(worker().is_err());
assert!(operation.acquire("Installation").is_ok());
}
#[test]
fn running_game_blocks_update_and_partial_locks_are_released() {
let operations = LauncherOperations::default();
let game = operations.game.acquire("game").unwrap();
assert!(operations.acquire_update().is_err());
assert!(operations.installation.acquire("install").is_ok());
assert!(operations.account.acquire("account").is_ok());
assert!(operations
.shacraft_account
.acquire("ShaCraft account")
.is_ok());
drop(game);
assert!(operations.acquire_update().is_ok());
}
#[test]
fn update_excludes_game_and_accounts_until_permit_drop() {
let operations = LauncherOperations::default();
let permit = operations.acquire_update().unwrap();
assert!(operations.installation.acquire("install").is_err());
assert!(operations.account.acquire("account").is_err());
assert!(operations
.shacraft_account
.acquire("ShaCraft account")
.is_err());
assert!(operations.game.acquire("game").is_err());
assert!(operations.acquire_update().is_err());
drop(permit);
assert!(operations.acquire_update().is_ok());
}
#[test]
fn account_operation_blocks_update_without_stranding_installation() {
let operations = LauncherOperations::default();
let _account = operations.shacraft_account.acquire("account").unwrap();
assert!(operations.acquire_update().is_err());
assert!(operations.installation.acquire("install").is_ok());
}
}
+727
View File
@@ -0,0 +1,727 @@
//! Launcher releases form a separate trust boundary from modpack manifests.
//! Only this module chooses update URLs. The webview receives display data and
//! progress, never an updater resource, destination, signature or public key.
use crate::operations::{Operation, UpdatePermits};
use base64::{engine::general_purpose::STANDARD, Engine};
use minisign_verify::{PublicKey, Signature};
use serde::Serialize;
use serde_json::Value;
use std::{
sync::{Arc, Mutex},
time::Duration,
};
use tauri::{AppHandle, Manager, Runtime};
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;
const MAX_PAYLOAD_BYTES: usize = 64 * 1024;
const MAX_ARTIFACT_BYTES: usize = 256 * 1024 * 1024;
const BAD_METADATA: &str =
"Не удалось подтвердить подлинность сведений об обновлении. Повторите проверку позже.";
const BAD_SIGNATURE: &str = "Подпись обновления не прошла проверку. Установка отменена.";
#[derive(Clone, Copy, Default, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Stage {
#[default]
Idle,
Checking,
Available,
Downloading,
Installing,
Ready,
}
#[derive(Default)]
pub(crate) struct UpdateState {
pub stage: Stage,
pub candidate: Option<Update>,
// Hold all mutation gates until the user restarts into the installed app.
pub restart_permits: Option<UpdatePermits>,
}
#[derive(Clone, Default)]
pub(crate) struct LauncherUpdater {
pub operation: Operation,
pub state: Arc<Mutex<UpdateState>>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UpdateStatus {
pub current_version: String,
pub supported: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub stage: Stage,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UpdateProgress {
pub stage: Stage,
pub downloaded_bytes: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_bytes: Option<u64>,
}
impl LauncherUpdater {
pub fn status<R: Runtime>(&self, app: &AppHandle<R>) -> Result<UpdateStatus, String> {
let reason = unsupported_reason(app);
let state = self
.state
.lock()
.map_err(|_| "Состояние обновления недоступно.".to_string())?;
Ok(UpdateStatus {
current_version: app.package_info().version.to_string(),
supported: reason.is_none(),
reason,
stage: state.stage,
version: state
.candidate
.as_ref()
.map(|update| update.version.clone()),
notes: state
.candidate
.as_ref()
.and_then(|update| update.body.clone()),
})
}
pub fn set_stage(&self, stage: Stage) -> Result<(), String> {
self.state
.lock()
.map_err(|_| "Состояние обновления недоступно.".to_string())?
.stage = stage;
Ok(())
}
}
pub(crate) fn unsupported_reason<R: Runtime>(app: &AppHandle<R>) -> Option<String> {
#[cfg(target_os = "linux")]
{
let env = app.env();
let valid = match (
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());
}
}
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
return Some("Для этой платформы доступна только ручная установка обновлений.".into());
None
}
#[cfg(target_os = "linux")]
fn linux_appimage_supported(
image: &std::path::Path,
directory: &std::path::Path,
executable: &std::path::Path,
) -> bool {
use std::io::Read;
if !image.is_absolute()
|| !directory.is_absolute()
|| !executable.starts_with(directory)
|| !image.is_file()
{
return false;
}
let mut header = [0_u8; 11];
std::fs::File::open(image)
.and_then(|mut file| file.read_exact(&mut header))
.is_ok()
&& &header[..4] == b"\x7fELF"
&& &header[8..11] == b"AI\x02"
}
pub(crate) fn public_key<R: Runtime>(app: &AppHandle<R>) -> Result<String, String> {
app.config()
.plugins
.0
.get("updater")
.and_then(|value| value.get("pubkey"))
.and_then(Value::as_str)
.filter(|key| !key.is_empty())
.map(str::to_owned)
.ok_or_else(|| "В этой сборке отсутствует ключ проверки обновлений.".into())
}
/// Resolve the startup AppImage path once. Release metadata and IPC never
/// select an installation destination; symlink launch shortcuts remain usable.
pub(crate) fn installation_path<R: Runtime>(
app: &AppHandle<R>,
) -> Result<Option<std::path::PathBuf>, String> {
#[cfg(target_os = "linux")]
{
let image = app
.env()
.appimage
.ok_or("Запустите AppImage, чтобы обновить лаунчер.")?;
return std::fs::canonicalize(image)
.map(Some)
.map_err(|_| "Файл AppImage перемещён или недоступен. Запустите его снова.".into());
}
#[cfg(not(target_os = "linux"))]
{
let _ = app;
Ok(None)
}
}
pub(crate) fn trusted_builder<R: Runtime>(app: &AppHandle<R>) -> Result<UpdaterBuilder, String> {
app.updater_builder()
.endpoints(vec![Url::parse(UPDATE_ENDPOINT).expect("fixed update URL")])
.map_err(|_| BAD_METADATA.to_string())
.map(|builder| {
builder
.timeout(Duration::from_secs(20))
.configure_client(|client| {
client
.https_only(true)
.redirect(reqwest_updater::redirect::Policy::none())
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(20))
.danger_accept_invalid_certs(false)
.danger_accept_invalid_hostnames(false)
})
})
}
fn http_client(timeout: Duration) -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.https_only(true)
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(10))
.timeout(timeout)
.user_agent(concat!("ShaCraft-Launcher/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|_| "Не удалось подключиться к серверу обновлений.".into())
}
fn checked_length(current: usize, next: usize, maximum: usize) -> Result<usize, String> {
current
.checked_add(next)
.filter(|size| *size <= maximum)
.ok_or_else(|| "Размер ответа сервера обновлений превышает допустимый.".into())
}
async fn bounded_response(
mut response: reqwest::Response,
maximum: usize,
mut progress: impl FnMut(u64, Option<u64>),
) -> Result<Vec<u8>, String> {
if !response.status().is_success() || response.status() == reqwest::StatusCode::NO_CONTENT {
return Err("Сервер обновлений временно недоступен. Повторите попытку позже.".into());
}
let total = response.content_length();
if total.is_some_and(|length| length > maximum as u64) {
return Err("Размер ответа сервера обновлений превышает допустимый.".into());
}
let mut bytes = Vec::new();
while let Some(chunk) = response.chunk().await.map_err(|_| {
"Загрузка обновления прервалась. Проверьте соединение и повторите попытку.".to_string()
})? {
checked_length(bytes.len(), chunk.len(), maximum)?;
bytes.extend_from_slice(&chunk);
progress(bytes.len() as u64, total);
}
if total.is_some_and(|length| length != bytes.len() as u64) {
return Err("Обновление загружено не полностью. Повторите попытку.".into());
}
Ok(bytes)
}
/// 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(
bytes: &[u8],
encoded_signature: &str,
encoded_key: &str,
) -> Result<(), String> {
if encoded_signature.len() > 4096 || encoded_key.len() > 4096 {
return Err(BAD_SIGNATURE.into());
}
let key_bytes = STANDARD.decode(encoded_key).map_err(|_| BAD_SIGNATURE)?;
let signature_bytes = STANDARD
.decode(encoded_signature)
.map_err(|_| BAD_SIGNATURE)?;
let key = PublicKey::decode(std::str::from_utf8(&key_bytes).map_err(|_| BAD_SIGNATURE)?)
.map_err(|_| BAD_SIGNATURE)?;
let signature =
Signature::decode(std::str::from_utf8(&signature_bytes).map_err(|_| BAD_SIGNATURE)?)
.map_err(|_| BAD_SIGNATURE)?;
key.verify(bytes, &signature, true)
.map_err(|_| BAD_SIGNATURE.into())
}
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());
}
let encoded = raw
.get("signedPayload")
.and_then(Value::as_str)
.ok_or(BAD_METADATA)?;
if encoded.len() > MAX_PAYLOAD_BYTES * 4 / 3 + 4 {
return Err(BAD_METADATA.into());
}
let payload = STANDARD.decode(encoded).map_err(|_| BAD_METADATA)?;
if payload.len() > MAX_PAYLOAD_BYTES {
return Err(BAD_METADATA.into());
}
let signature = raw
.get("metadataSignature")
.and_then(Value::as_str)
.ok_or(BAD_METADATA)?;
verify_signature(&payload, signature, key).map_err(|_| BAD_METADATA)?;
let parsed: Value = serde_json::from_slice(&payload).map_err(|_| BAD_METADATA)?;
let signed = parsed.as_object().ok_or(BAD_METADATA)?;
if signed.len() != 4 {
return Err(BAD_METADATA.into());
}
for field in ["version", "notes", "pub_date", "platforms"] {
if !signed.contains_key(field) || signed.get(field) != object.get(field) {
return Err(BAD_METADATA.into());
}
}
Ok(parsed)
}
fn newer_version(metadata: &Value, current: &str) -> Result<bool, String> {
let announced = metadata
.get("version")
.and_then(Value::as_str)
.ok_or(BAD_METADATA)?;
let version = semver::Version::parse(announced).map_err(|_| BAD_METADATA)?;
// Stable channel rejects prerelease/build aliases and noncanonical spellings.
if !version.pre.is_empty() || !version.build.is_empty() || version.to_string() != announced {
return Err(BAD_METADATA.into());
}
let current = semver::Version::parse(current).map_err(|_| BAD_METADATA)?;
Ok(version > current)
}
fn require_platform(metadata: &Value) -> Result<(), String> {
let os = if cfg!(target_os = "macos") {
"darwin"
} else {
std::env::consts::OS
};
let target = format!("{os}-{}", std::env::consts::ARCH);
let platforms = metadata
.get("platforms")
.and_then(Value::as_object)
.ok_or(BAD_METADATA)?;
let available = platforms.contains_key(&target)
|| ["appimage", "nsis", "msi", "app"]
.iter()
.any(|bundle| platforms.contains_key(&format!("{target}-{bundle}")));
if !available {
return Err("Обновление для вашей платформы пока не опубликовано.".into());
}
Ok(())
}
fn validate_download_url(url: &Url, version: &str) -> Result<(), String> {
let prefix = format!("/downloads/shacraft-launcher/{version}/");
let filename = url.path().strip_prefix(&prefix).ok_or(BAD_METADATA)?;
if url.scheme() != "https"
|| url.host_str() != Some("shacraft.ru")
|| url.port().is_some()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
|| filename.is_empty()
|| !filename.as_bytes()[0].is_ascii_alphanumeric()
|| !filename
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
{
return Err(BAD_METADATA.into());
}
let correct_extension = if cfg!(target_os = "linux") {
filename.ends_with(".AppImage")
} else if cfg!(target_os = "macos") {
filename.ends_with(".app.tar.gz")
} else if cfg!(target_os = "windows") {
filename.ends_with(".exe") || filename.ends_with(".msi")
} else {
false
};
if !correct_extension {
return Err(BAD_METADATA.into());
}
Ok(())
}
/// 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.
pub(crate) async fn check_candidate(
builder: UpdaterBuilder,
key: &str,
current: &str,
) -> Result<Option<Update>, String> {
let response = http_client(Duration::from_secs(20))?
.get(UPDATE_ENDPOINT)
.send()
.await
.map_err(|_| {
"Не удалось проверить обновления. Проверьте подключение к интернету.".to_string()
})?;
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)?;
if !newer_version(&metadata, current)? {
return Ok(None);
}
let update = builder
.build()
.map_err(updater_error)?
.check_metadata(raw.clone())
.map_err(updater_error)?
.ok_or(BAD_METADATA)?;
if update.raw_json != raw
|| update.current_version != current
|| Some(update.version.as_str()) != metadata.get("version").and_then(Value::as_str)
{
return Err(BAD_METADATA.into());
}
// Retain the exact signed envelope with the native-only candidate.
verified_metadata(&update.raw_json, key)?;
validate_download_url(&update.download_url, &update.version)?;
Ok(Some(update))
}
pub(crate) async fn download_verified(
update: &Update,
key: &str,
progress: impl FnMut(u64, Option<u64>),
) -> Result<Vec<u8>, String> {
validate_download_url(&update.download_url, &update.version)?;
verified_metadata(&update.raw_json, key)?;
let response = http_client(Duration::from_secs(600))?
.get(update.download_url.clone())
.send()
.await
.map_err(|_| {
"Не удалось загрузить обновление. Проверьте подключение и повторите попытку."
.to_string()
})?;
let bytes = bounded_response(response, MAX_ARTIFACT_BYTES, progress).await?;
verify_signature(&bytes, &update.signature, key)?;
Ok(bytes)
}
/// Keep verification adjacent to the only call that can replace the app. This
/// also protects against an accidental mutation of bytes after downloading.
pub(crate) fn install_verified(
update: &Update,
bytes: &[u8],
key: &str,
destination: Option<&std::path::Path>,
) -> Result<(), String> {
validate_download_url(&update.download_url, &update.version)?;
verify_signature(bytes, &update.signature, key)?;
#[cfg(target_os = "linux")]
{
let destination = destination.ok_or("Файл AppImage недоступен.")?;
install_appimage_atomic(destination, bytes).map_err(|_| {
"Не удалось заменить AppImage. Проверьте свободное место и права на папку лаунчера."
.into()
})
}
#[cfg(not(target_os = "linux"))]
{
let _ = destination;
update.install(bytes).map_err(updater_error)
}
}
/// Tauri 2.11 moves the old AppImage away before writing the new one. Use our
/// atomic-file primitive on Linux so interruption during writing leaves the
/// old executable intact. Other OS installers stay with the official plugin.
#[cfg(target_os = "linux")]
fn install_appimage_atomic(destination: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::{
fs,
io::{self, Write},
os::unix::fs::PermissionsExt,
};
if bytes.len() < 11 || &bytes[..4] != b"\x7fELF" || &bytes[8..11] != b"AI\x02" {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"release is not a type-2 AppImage",
));
}
let metadata = fs::symlink_metadata(destination)?;
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"AppImage must be a regular file",
));
}
let parent =
fs::File::open(destination.parent().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "AppImage has no parent")
})?)?;
let mut output = crate::storage::AtomicFile::new(destination)?;
output.writer().write_all(bytes)?;
output.writer().set_permissions(fs::Permissions::from_mode(
metadata.permissions().mode() & 0o777,
))?;
output.commit()?;
parent.sync_all()
}
pub(crate) fn updater_error(error: tauri_plugin_updater::Error) -> String {
match error {
tauri_plugin_updater::Error::TargetNotFound(_) | tauri_plugin_updater::Error::TargetsNotFound(_) =>
"Обновление для вашей платформы пока не опубликовано.".into(),
tauri_plugin_updater::Error::Io(_) | tauri_plugin_updater::Error::TempDirNotOnSameMountPoint =>
"Не удалось заменить файл лаунчера. Проверьте свободное место и права на папку приложения.".into(),
tauri_plugin_updater::Error::Minisign(_) | tauri_plugin_updater::Error::Base64(_) |
tauri_plugin_updater::Error::SignatureUtf8(_) => BAD_SIGNATURE.into(),
_ => "Не удалось установить обновление. Повторите попытку или скачайте лаунчер с shacraft.ru.".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn artifact_url() -> &'static str {
if cfg!(target_os = "linux") {
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0.AppImage"
} else if cfg!(target_os = "macos") {
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0.app.tar.gz"
} else {
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0.exe"
}
}
#[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());
for value in [
artifact_url().replace("https:", "http:"),
artifact_url().replace("shacraft.ru/", "evil.example/"),
artifact_url().replace("shacraft.ru/", "shacraft.ru:8443/"),
artifact_url().replace("https://", "https://user@"),
format!("{}?url=x", artifact_url()),
format!("{}#x", artifact_url()),
artifact_url().replace("/0.2.0/", "/0.1.0/"),
artifact_url().replace("ShaCraft_", "%53haCraft_"),
artifact_url().replace("ShaCraft_", "nested/ShaCraft_"),
format!("{}.sh", artifact_url()),
] {
assert!(
validate_download_url(&Url::parse(&value).unwrap(), "0.2.0").is_err(),
"{value}"
);
}
}
#[test]
fn stable_channel_never_downgrades_or_installs_equal_aliases() {
assert!(newer_version(&serde_json::json!({"version":"0.2.0"}), "0.1.3").unwrap());
for version in ["0.1.2", "0.1.3"] {
assert!(!newer_version(&serde_json::json!({"version":version}), "0.1.3").unwrap());
}
for version in ["v0.2.0", "0.2.0-test", "0.2.0+extra", "00.2.0", "../0.2.0"] {
assert!(newer_version(&serde_json::json!({"version":version}), "0.1.3").is_err());
}
}
#[test]
fn streaming_size_limit_handles_missing_length_and_overflow() {
assert_eq!(checked_length(3, 5, 8).unwrap(), 8);
assert!(checked_length(3, 6, 8).is_err());
assert!(checked_length(usize::MAX, 1, usize::MAX).is_err());
}
#[test]
fn missing_or_oversized_metadata_proof_is_rejected() {
assert!(verified_metadata(&serde_json::json!({"version":"0.2.0"}), "").is_err());
let raw = serde_json::json!({"version":"0.2.0","notes":"","pub_date":"","platforms":{},
"signedPayload":"A".repeat(MAX_PAYLOAD_BYTES * 2),"metadataSignature":""});
assert!(verified_metadata(&raw, "").is_err());
}
fn fixture() -> Value {
// Public test key/signatures only. The ephemeral private key was
// discarded by the publisher tests and never enters this repository.
serde_json::from_str(include_str!("../tests/fixtures/updater-signed.json")).unwrap()
}
#[test]
fn genuine_metadata_and_artifact_signatures_pass_and_tampering_fails() {
let fixture = fixture();
let key = fixture["publicKey"].as_str().unwrap();
let raw = &fixture["metadata"];
assert!(verified_metadata(raw, key).is_ok());
let signature = raw["platforms"]["linux-x86_64"]["signature"]
.as_str()
.unwrap();
let bytes = fixture["artifactText"].as_str().unwrap().as_bytes();
assert!(verify_signature(bytes, signature, key).is_ok());
let mut corrupt = bytes.to_vec();
corrupt[0] ^= 1;
assert!(verify_signature(&corrupt, signature, key).is_err());
assert!(
verify_signature(bytes, &STANDARD.encode("invalid minisign signature"), key).is_err()
);
assert!(
verify_signature(bytes, signature, &STANDARD.encode("invalid public key")).is_err()
);
}
#[test]
fn relabelling_a_signed_old_artifact_or_changing_signed_fields_fails() {
let fixture = fixture();
let key = fixture["publicKey"].as_str().unwrap();
for field in ["version", "notes", "pub_date", "platforms"] {
let mut raw = fixture["metadata"].clone();
raw[field] = Value::String("tampered".into());
assert!(verified_metadata(&raw, key).is_err(), "{field}");
}
let mut raw = fixture["metadata"].clone();
let mut payload: Value = serde_json::from_slice(
&STANDARD
.decode(raw["signedPayload"].as_str().unwrap())
.unwrap(),
)
.unwrap();
raw["version"] = Value::String("99.0.0".into());
payload["version"] = Value::String("99.0.0".into());
raw["signedPayload"] =
Value::String(STANDARD.encode(serde_json::to_vec(&payload).unwrap()));
assert!(verified_metadata(&raw, key).is_err());
}
#[test]
fn unavailable_platform_is_not_reported_as_latest() {
assert!(require_platform(&serde_json::json!({"platforms":{}})).is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn linux_support_requires_actual_appimage_and_matching_appdir() {
let directory = tempfile::tempdir().unwrap();
let image = directory.path().join("launcher.AppImage");
let appdir = directory.path().join(".mount_test");
let binary = appdir.join("usr/bin/shacraft-launcher");
std::fs::write(&image, b"\x7fELF\x02\x01\x01\0AI\x02rest").unwrap();
assert!(linux_appimage_supported(&image, &appdir, &binary));
assert!(!linux_appimage_supported(
&image,
&appdir,
&directory.path().join("raw-binary")
));
std::fs::write(&image, b"not a valid AppImage").unwrap();
assert!(!linux_appimage_supported(&image, &appdir, &binary));
}
#[cfg(target_os = "linux")]
#[test]
fn appimage_replacement_preserves_old_on_error_and_retains_executable_mode() {
use std::os::unix::fs::PermissionsExt;
let directory = tempfile::tempdir().unwrap();
let image = directory.path().join("launcher.AppImage");
let old = b"\x7fELF\x02\x01\x01\0AI\x02old";
let new = b"\x7fELF\x02\x01\x01\0AI\x02new";
std::fs::write(&image, old).unwrap();
std::fs::set_permissions(&image, std::fs::Permissions::from_mode(0o751)).unwrap();
assert!(install_appimage_atomic(&image, b"wrong package").is_err());
assert_eq!(std::fs::read(&image).unwrap(), old);
assert!(install_appimage_atomic(&directory.path().join("missing"), new).is_err());
assert_eq!(std::fs::read(&image).unwrap(), old);
install_appimage_atomic(&image, new).unwrap();
assert_eq!(std::fs::read(&image).unwrap(), new);
assert_eq!(
std::fs::metadata(&image).unwrap().permissions().mode() & 0o777,
0o751
);
assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 1);
}
/// Read-only production HTTPS requests; replacement occurs ONLY in a new
/// temporary copy of the path supplied by the operator. No QA switches or
/// alternative endpoints are compiled into a distributed launcher.
#[cfg(target_os = "linux")]
#[test]
#[ignore = "requires published signed update and SHACRAFT_UPDATER_TEST_IMAGE pointing to an old AppImage"]
fn live_signed_update_replaces_only_temporary_copy() {
use sha2::{Digest, Sha256};
let source = std::path::PathBuf::from(
std::env::var_os("SHACRAFT_UPDATER_TEST_IMAGE").expect("old AppImage path"),
);
assert!(source.is_file());
let directory = tempfile::tempdir().unwrap();
let destination = directory.path().join("isolated-old.AppImage");
std::fs::copy(&source, &destination).unwrap();
let old_hash = Sha256::digest(std::fs::read(&destination).unwrap());
let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
let mut context = tauri::test::mock_context(tauri::test::noop_assets());
context
.config_mut()
.plugins
.0
.insert("updater".into(), config["plugins"]["updater"].clone());
context.package_info_mut().version = "0.1.2".parse().unwrap();
let app = tauri::test::mock_builder()
.plugin(tauri_plugin_updater::Builder::new().build())
.build(context)
.unwrap();
let key = public_key(app.handle()).unwrap();
let builder = trusted_builder(app.handle())
.unwrap()
.executable_path(&destination);
tauri::async_runtime::block_on(async {
let update = check_candidate(builder, &key, "0.1.2")
.await
.unwrap()
.expect("newer published version");
let mut bytes = download_verified(&update, &key, |_, _| {}).await.unwrap();
let new_hash = Sha256::digest(&bytes);
assert_ne!(old_hash, new_hash);
bytes[0] ^= 1;
assert!(install_verified(&update, &bytes, &key, Some(&destination)).is_err());
assert_eq!(
old_hash,
Sha256::digest(std::fs::read(&destination).unwrap())
);
bytes[0] ^= 1;
install_verified(&update, &bytes, &key, Some(&destination)).unwrap();
assert_eq!(
new_hash,
Sha256::digest(std::fs::read(&destination).unwrap())
);
assert_eq!(old_hash, Sha256::digest(std::fs::read(&source).unwrap()));
println!(
"Verified signed update {} and tamper rejection; replaced only isolated copy",
update.version
);
});
}
}