fix: bind AppImage updates to the running bundle

This commit is contained in:
Emil
2026-09-09 20:10:24 +03:00
parent b2ac174398
commit 696c6b0e52
4 changed files with 187 additions and 6 deletions
+3
View File
@@ -61,6 +61,9 @@ payload are in `/root/shacraft` on the ShaCraft host; see
authenticated with the pinned Tauri/minisign public key before JSON parsing. authenticated with the pinned Tauri/minisign public key before JSON parsing.
Each exact version/platform artifact also requires that signature, SHA-256 Each exact version/platform artifact also requires that signature, SHA-256
and size. Never reuse the ShaCraft mod-manifest key or accept IPC URLs/keys. and size. Never reuse the ShaCraft mod-manifest key or accept IPC URLs/keys.
- Linux self-update requires the original ordinary AppImage file and the frozen
Tauri `APPDIR` bound to the running `usr/bin/shacraft-launcher`. Extracted or
inherited AppImage context is manual-only; never replace a bare binary.
- `update_guard.rs` retains one launcher-instance OS lock, drains native writes - `update_guard.rs` retains one launcher-instance OS lock, drains native writes
through `operations::Lifecycle`, and checks the existing detached-game lease. through `operations::Lifecycle`, and checks the existing detached-game lease.
`launcher-state/pending-update.json` survives installer handoff; only startup `launcher-state/pending-update.json` survives installer handoff; only startup
+5 -1
View File
@@ -220,7 +220,11 @@ The webview supplies no URLs, public keys, executable arguments, release version
or arbitrary file path. No generic updater plugin permission is granted to it. or arbitrary file path. No generic updater plugin permission is granted to it.
The packaged native architecture selects the artifact: Windows preserves MSI The packaged native architecture selects the artifact: Windows preserves MSI
versus NSIS, macOS preserves Intel versus Apple Silicon, Linux only replaces an versus NSIS, macOS preserves Intel versus Apple Silicon, Linux only replaces an
AppImage. A Debian installation requires the user's package manager. AppImage. The original non-symlink AppImage must have the expected native header,
and the frozen Tauri `APPDIR` must contain the actual running executable at
`usr/bin/shacraft-launcher`. Extracted binaries and inherited context from another
AppImage require manual installation. A Debian installation requires the user's
package manager.
Checks run once per application UI lifecycle and on explicit request. They do Checks run once per application UI lifecycle and on explicit request. They do
not install automatically. The settings drawer shows installed/available not install automatically. The settings drawer shows installed/available
+4 -1
View File
@@ -184,7 +184,10 @@ updates preserve it:
into an appropriate Applications directory before use. A protected destination into an appropriate Applications directory before use. A protected destination
may require OS permission or a manual replacement; an installation error never may require OS permission or a manual replacement; an installation error never
counts as a completed update. counts as a completed update.
- Linux x64 AppImage → x64 AppImage at its current writable location. Debian - Linux x64 AppImage → x64 AppImage at its current writable location. The original
ordinary, non-symlink AppImage must exist, and the frozen Tauri `APPDIR` must
match the running `usr/bin/shacraft-launcher`. Extracted AppDirs and an inherited
environment from another AppImage use manual installation. Debian
packages, RPM, bare development binaries and unsupported architectures never packages, RPM, bare development binaries and unsupported architectures never
enter the self-replacement path. Install a new deb through the system package enter the self-replacement path. Install a new deb through the system package
manager; the launcher does not run privileged package-manager commands. manager; the launcher does not run privileged package-manager commands.
+175 -4
View File
@@ -14,6 +14,8 @@ use std::{
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::{Duration, Instant}, time::{Duration, Instant},
}; };
#[cfg(target_os = "linux")]
use tauri::Manager;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tauri_plugin_updater::UpdaterExt; use tauri_plugin_updater::UpdaterExt;
@@ -181,11 +183,82 @@ fn package_mode(
} }
} }
fn current_mode() -> PackageMode { // The stamped bundle type survives extracting an AppImage. It does not identify
// the runtime file which the plugin will replace. Only the frozen Tauri Env is
// shared with the plugin's executable_path selection; do not reread process env.
#[cfg(any(target_os = "linux", test))]
fn appimage_context_valid(path: Option<&Path>, ordinary_file: bool, header: &[u8]) -> bool {
path.is_some_and(Path::is_absolute)
&& ordinary_file
&& format::verify(
&PackageMode::Automatic {
platform: "linux-x86_64",
msi: false,
},
header,
)
.is_ok()
}
#[cfg(any(target_os = "linux", test))]
fn appimage_file_ready(path: Option<&Path>) -> bool {
use std::io::Read;
let Some(path) = path else { return false };
let ordinary_file = std::fs::symlink_metadata(path)
.map(|metadata| metadata.file_type().is_file())
.unwrap_or(false);
if !path.is_absolute() || !ordinary_file {
return false;
}
let mut header = [0; 20];
if std::fs::File::open(path)
.and_then(|mut file| file.read_exact(&mut header))
.is_err()
{
return false;
}
appimage_context_valid(Some(path), ordinary_file, &header)
}
// Binding APPDIR to the actual executable rejects APPIMAGE/APPDIR inherited
// from an unrelated parent application. The fixed relative path is the verified
// Tauri AppDir layout for this application's configured binary name.
#[cfg(any(target_os = "linux", test))]
fn appdir_matches_executable(appdir: Option<&Path>, executable: Option<&Path>) -> bool {
let (Some(appdir), Some(executable)) = (appdir, executable) else {
return false;
};
if !appdir.is_absolute() || !executable.is_absolute() {
return false;
}
let (Ok(appdir), Ok(executable)) = (appdir.canonicalize(), executable.canonicalize()) else {
return false;
};
appdir.is_dir()
&& executable.is_file()
&& executable.strip_prefix(&appdir).ok() == Some(Path::new("usr/bin/shacraft-launcher"))
}
#[cfg(target_os = "linux")]
fn appimage_runtime_ready(app: &AppHandle) -> bool {
let environment = app.env();
let executable = std::env::current_exe().ok();
appimage_file_ready(environment.appimage.as_deref().map(Path::new))
&& appdir_matches_executable(
environment.appdir.as_deref().map(Path::new),
executable.as_deref(),
)
}
fn current_mode(_app: &AppHandle) -> PackageMode {
// Bare binaries, distro packages and dev runs must never be overwritten as an AppImage/.app. // Bare binaries, distro packages and dev runs must never be overwritten as an AppImage/.app.
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
return PackageMode::Manual; return PackageMode::Manual;
} }
#[cfg(target_os = "linux")]
if !appimage_runtime_ready(_app) {
return PackageMode::Manual;
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
let app_bundle = std::env::current_exe() let app_bundle = std::env::current_exe()
@@ -275,12 +348,12 @@ pub(crate) fn check(app: &AppHandle, state: &UpdaterState) -> Result<UpdateStatu
else { else {
return Ok(state.phase(app, Phase::NoUpdate, None)); return Ok(state.phase(app, Phase::NoUpdate, None));
}; };
let manual = current_mode() == PackageMode::Manual; let manual = current_mode(app) == PackageMode::Manual;
Ok(state.change(app, |data| { Ok(state.change(app, |data| {
data.status.available_version = Some(verified.release.version.clone()); data.status.available_version = Some(verified.release.version.clone());
data.status.release_notes = Some(verified.release.notes.clone()); data.status.release_notes = Some(verified.release.notes.clone());
data.status.phase = if manual { Phase::Manual } else { Phase::Available }; data.status.phase = if manual { Phase::Manual } else { Phase::Available };
data.status.message = manual.then(|| "Эта сборка обновляется вручную. Для .deb используйте менеджер пакетов; AppImage и установленные Windows/macOS пакеты поддерживают обновление внутри лаунчера.".into()); data.status.message = manual.then(|| "Эта сборка обновляется вручную. Для .deb используйте менеджер пакетов. Автообновление AppImage доступно при запуске исходного файла .AppImage; распакованная копия обновляется вручную.".into());
data.candidate = Some(verified); data.candidate = Some(verified);
})) }))
} }
@@ -320,7 +393,7 @@ pub(crate) fn download_install(
if !release.is_newer_than(env!("CARGO_PKG_VERSION"))? { if !release.is_newer_than(env!("CARGO_PKG_VERSION"))? {
return Err("Эта версия уже установлена".into()); return Err("Эта версия уже установлена".into());
} }
let mode = current_mode(); let mode = current_mode(app);
let artifact = select_artifact(&release, &mode)?; let artifact = select_artifact(&release, &mode)?;
// Includes cross-process game lease and lifecycle exclusion; held through restart/handoff. // Includes cross-process game lease and lifecycle exclusion; held through restart/handoff.
let guard = UpdateGuard::acquire(directory, operations)?; let guard = UpdateGuard::acquire(directory, operations)?;
@@ -482,6 +555,104 @@ mod tests {
} }
); );
} }
#[test]
fn appimage_runtime_requires_absolute_ordinary_image_file() {
let absolute = std::env::temp_dir().join("launcher.AppImage");
let mut header = [0; 20];
header[..6].copy_from_slice(b"\x7fELF\x02\x01");
header[8..11].copy_from_slice(b"AI\x02");
header[18..20].copy_from_slice(b"\x3e\x00");
assert!(appimage_context_valid(Some(&absolute), true, &header));
// A stamped extracted binary has no APPIMAGE runtime path. A relative
// path, missing file/directory/symlink or ordinary ELF is also manual.
assert!(!appimage_context_valid(None, true, &header));
assert!(!appimage_context_valid(
Some(Path::new("launcher.AppImage")),
true,
&header
));
assert!(!appimage_context_valid(Some(&absolute), false, &header));
assert!(!appimage_context_valid(
Some(&absolute),
true,
&header[..10]
));
header[8..11].fill(0);
assert!(!appimage_context_valid(Some(&absolute), true, &header));
}
#[test]
fn appimage_file_context_rejects_missing_directory_symlink_and_raw_binary() {
let root = std::env::temp_dir().join(format!(
"shacraft-appimage-context-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
std::fs::create_dir_all(&root).unwrap();
let path = root.join("launcher.AppImage");
assert!(!appimage_file_ready(None));
assert!(!appimage_file_ready(Some(&path)));
assert!(!appimage_file_ready(Some(&root)));
let mut header = [0; 20];
header[..6].copy_from_slice(b"\x7fELF\x02\x01");
header[18..20].copy_from_slice(b"\x3e\x00");
std::fs::write(&path, header).unwrap();
assert!(!appimage_file_ready(Some(&path))); // ordinary extracted ELF
header[8..11].copy_from_slice(b"AI\x02");
std::fs::write(&path, header).unwrap();
assert!(appimage_file_ready(Some(&path))); // runtime image header fixture
#[cfg(unix)]
{
let link = root.join("linked.AppImage");
std::os::unix::fs::symlink(&path, &link).unwrap();
assert!(!appimage_file_ready(Some(&link)));
}
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn appdir_binding_rejects_inherited_parent_context_and_escaped_executable() {
let root = std::env::temp_dir().join(format!(
"shacraft-appdir-context-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
let appdir = root.join("ShaCraft.AppDir");
let executable = appdir.join("usr/bin/shacraft-launcher");
std::fs::create_dir_all(executable.parent().unwrap()).unwrap();
std::fs::write(&executable, b"fixture").unwrap();
let parent_appdir = root.join("Other.AppDir");
std::fs::create_dir(&parent_appdir).unwrap();
let bare = root.join("shacraft-launcher");
std::fs::write(&bare, b"fixture").unwrap();
assert!(appdir_matches_executable(Some(&appdir), Some(&executable)));
assert!(!appdir_matches_executable(None, Some(&executable)));
assert!(!appdir_matches_executable(Some(&appdir), None));
assert!(!appdir_matches_executable(
Some(Path::new("relative.AppDir")),
Some(&executable)
));
assert!(!appdir_matches_executable(
Some(&parent_appdir),
Some(&executable)
));
assert!(!appdir_matches_executable(Some(&appdir), Some(&bare)));
assert!(!appdir_matches_executable(Some(&root), Some(&executable))); // arbitrary ancestor is not sufficient
#[cfg(unix)]
{
std::fs::remove_file(&executable).unwrap();
std::os::unix::fs::symlink(&bare, &executable).unwrap();
assert!(!appdir_matches_executable(Some(&appdir), Some(&executable)));
}
std::fs::remove_dir_all(root).unwrap();
}
#[test] #[test]
fn updater_single_flight_releases_after_failure() { fn updater_single_flight_releases_after_failure() {
let state = UpdaterState::default(); let state = UpdaterState::default();