Support signed deb self-updates with system authorization in 0.1.4
This commit is contained in:
@@ -37,6 +37,7 @@ pub(crate) async fn check_launcher_update(
|
||||
updater::trusted_builder(&app)?,
|
||||
&key,
|
||||
&app.package_info().version.to_string(),
|
||||
updater::installation_kind(&app),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -190,5 +191,13 @@ pub(crate) fn restart_launcher_after_update(
|
||||
return Err("Сначала установите обновление лаунчера.".into());
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if updater::installation_kind(&app) == updater::InstallationKind::Deb
|
||||
|| crate::deb_updater::is_deleted_installed_binary()
|
||||
{
|
||||
crate::deb_updater::restart()?;
|
||||
app.exit(0);
|
||||
return Ok(());
|
||||
}
|
||||
app.restart()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
//! The only elevated updater operation. pkexec starts this installed, root-owned
|
||||
//! binary in an early non-GUI mode. Input is untrusted until BOTH signatures
|
||||
//! are verified again here. No user-supplied path, command or password is used.
|
||||
use crate::updater::{self, InstallationKind, MAX_ARTIFACT_BYTES, MAX_METADATA_BYTES};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, Read, Write},
|
||||
os::unix::{
|
||||
fs::{MetadataExt, PermissionsExt},
|
||||
process::CommandExt,
|
||||
},
|
||||
path::Path,
|
||||
process::{Command, ExitStatus, Stdio},
|
||||
sync::mpsc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tauri_plugin_updater::Update;
|
||||
use url::Url;
|
||||
|
||||
const BINARY: &str = "/usr/bin/shacraft-launcher";
|
||||
const HELPER_FLAG: &str = "--shacraft-install-deb";
|
||||
const PACKAGE: &str = "sha-craft-launcher";
|
||||
const PKEXEC: &str = "/usr/bin/pkexec";
|
||||
const DPKG: &str = "/usr/bin/dpkg";
|
||||
const QUERY: &str = "/usr/bin/dpkg-query";
|
||||
const DEB: &str = "/usr/bin/dpkg-deb";
|
||||
const OUTPUT_LIMIT: usize = 16 * 1024;
|
||||
const INPUT_MAGIC: &[u8; 8] = b"SCDUPD01";
|
||||
const REJECTED: i32 = 20;
|
||||
const LOCKED: i32 = 21;
|
||||
const INSTALL_FAILED: i32 = 22;
|
||||
const INVALID_HOST: i32 = 23;
|
||||
|
||||
fn architecture() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"x86_64" => "amd64",
|
||||
"aarch64" => "arm64",
|
||||
_ => "unsupported",
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the full path without following symlinks. The executable and every
|
||||
/// parent must be root-owned and not writable by group/other users.
|
||||
fn trusted_root_path(path: &Path, executable: bool) -> bool {
|
||||
if !path.is_absolute()
|
||||
|| path
|
||||
.components()
|
||||
.any(|c| matches!(c, std::path::Component::ParentDir))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut leaf = true;
|
||||
for item in path.ancestors() {
|
||||
let Ok(meta) = fs::symlink_metadata(item) else {
|
||||
return false;
|
||||
};
|
||||
if meta.file_type().is_symlink() || meta.uid() != 0 || meta.mode() & 0o022 != 0 {
|
||||
return false;
|
||||
}
|
||||
if leaf && executable {
|
||||
if !meta.is_file() || meta.mode() & 0o111 == 0 {
|
||||
return false;
|
||||
}
|
||||
} else if !meta.is_dir() {
|
||||
return false;
|
||||
}
|
||||
leaf = false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn safe_sticky_temporary_parent(path: &Path) -> bool {
|
||||
fs::symlink_metadata(path).is_ok_and(|meta| {
|
||||
meta.is_dir()
|
||||
&& !meta.file_type().is_symlink()
|
||||
&& meta.uid() == 0
|
||||
&& (meta.mode() & 0o022 == 0 || meta.mode() & 0o1000 != 0)
|
||||
})
|
||||
}
|
||||
|
||||
fn fixed_command(path: &str) -> Command {
|
||||
let mut command = Command::new(path);
|
||||
command
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin")
|
||||
.env("LC_ALL", "C");
|
||||
command
|
||||
}
|
||||
|
||||
fn drain_capped(mut source: impl Read) -> io::Result<Vec<u8>> {
|
||||
let mut result = Vec::new();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
loop {
|
||||
let read = source.read(&mut buffer)?;
|
||||
if read == 0 {
|
||||
return Ok(result);
|
||||
}
|
||||
let remaining = OUTPUT_LIMIT.saturating_sub(result.len());
|
||||
result.extend_from_slice(&buffer[..read.min(remaining)]);
|
||||
}
|
||||
}
|
||||
|
||||
struct Captured {
|
||||
status: ExitStatus,
|
||||
stdout: Vec<u8>,
|
||||
stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Drain both pipes concurrently; output can never make the root helper buffer
|
||||
/// unbounded data or deadlock dpkg while it is changing the package database.
|
||||
fn capture(command: &mut Command) -> io::Result<Captured> {
|
||||
capture_with_deadline(command, Duration::from_secs(15))
|
||||
}
|
||||
|
||||
fn capture_with_deadline(command: &mut Command, deadline: Duration) -> io::Result<Captured> {
|
||||
// Read-only inspection has a deadline. Never kill dpkg during mutation:
|
||||
// interrupting it could leave a partially configured installed package.
|
||||
let inspection = command.get_program() != DPKG;
|
||||
if inspection {
|
||||
command.process_group(0);
|
||||
}
|
||||
let mut child = command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
let stderr = child.stderr.take().expect("piped stderr");
|
||||
let (out_send, out_receive) = mpsc::channel();
|
||||
let (err_send, err_receive) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = out_send.send(drain_capped(stdout));
|
||||
});
|
||||
std::thread::spawn(move || {
|
||||
let _ = err_send.send(drain_capped(stderr));
|
||||
});
|
||||
let start = Instant::now();
|
||||
let mut stdout = None;
|
||||
let mut stderr = None;
|
||||
loop {
|
||||
if stdout.is_none() {
|
||||
stdout = out_receive.try_recv().ok();
|
||||
}
|
||||
if stderr.is_none() {
|
||||
stderr = err_receive.try_recv().ok();
|
||||
}
|
||||
// Do not reap the parent before its pipes close. Its unreaped PID
|
||||
// reserves the process-group id until a possible timeout kill below.
|
||||
if stdout.is_some() && stderr.is_some() {
|
||||
if let Some(status) = child.try_wait()? {
|
||||
return Ok(Captured {
|
||||
status,
|
||||
stdout: stdout.unwrap()?,
|
||||
stderr: stderr.unwrap()?,
|
||||
});
|
||||
}
|
||||
}
|
||||
if inspection && start.elapsed() > deadline {
|
||||
// Also terminate dpkg-deb's decompressor descendants so they cannot
|
||||
// retain the pipes after the inspection parent has been killed.
|
||||
unsafe {
|
||||
libc::kill(-(child.id() as i32), libc::SIGKILL);
|
||||
}
|
||||
let _ = child.wait();
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"package inspection timed out",
|
||||
));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn installed_version() -> Result<String, ()> {
|
||||
let result = capture(fixed_command(QUERY).args([
|
||||
"--show",
|
||||
"--showformat=${db:Status-Status}\n${Version}\n${Architecture}\n",
|
||||
PACKAGE,
|
||||
]))
|
||||
.map_err(|_| ())?;
|
||||
if !result.status.success() {
|
||||
return Err(());
|
||||
}
|
||||
let fields = std::str::from_utf8(&result.stdout)
|
||||
.map_err(|_| ())?
|
||||
.lines()
|
||||
.collect::<Vec<_>>();
|
||||
if fields.len() != 3 || fields[0] != "installed" || fields[2] != architecture() {
|
||||
return Err(());
|
||||
}
|
||||
let version = semver::Version::parse(fields[1]).map_err(|_| ())?;
|
||||
if version.to_string() != fields[1] || !version.pre.is_empty() || !version.build.is_empty() {
|
||||
return Err(());
|
||||
}
|
||||
// dpkg's database must also assign the precise executable to our package.
|
||||
let owner = capture(fixed_command(QUERY).args(["--search", BINARY])).map_err(|_| ())?;
|
||||
if !owner.status.success() || owner.stdout != format!("{PACKAGE}: {BINARY}\n").as_bytes() {
|
||||
return Err(());
|
||||
}
|
||||
Ok(fields[1].to_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn installed_binary_supported() -> bool {
|
||||
std::env::current_exe().is_ok_and(|path| path == Path::new(BINARY))
|
||||
&& trusted_root_path(Path::new(BINARY), true)
|
||||
&& [QUERY, DEB, DPKG]
|
||||
.iter()
|
||||
.all(|path| trusted_root_path(Path::new(path), true))
|
||||
&& installed_version().is_ok()
|
||||
}
|
||||
|
||||
pub(crate) fn unsupported_reason() -> Option<String> {
|
||||
if trusted_root_path(Path::new(PKEXEC), true) {
|
||||
None
|
||||
} else {
|
||||
Some("Для обновления deb нужен системный компонент pkexec (PolicyKit). Установите его или скачайте новый deb с shacraft.ru/help#launcher.".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_deleted_installed_binary() -> bool {
|
||||
std::env::current_exe()
|
||||
.is_ok_and(|path| path == Path::new("/usr/bin/shacraft-launcher (deleted)"))
|
||||
}
|
||||
|
||||
pub(crate) fn restart() -> Result<(), String> {
|
||||
if !trusted_root_path(Path::new(BINARY), true) {
|
||||
return Err("Установленный лаунчер недоступен. Запустите его из меню приложений.".into());
|
||||
}
|
||||
Command::new(BINARY).spawn().map_err(|_| {
|
||||
"Не удалось перезапустить лаунчер. Запустите его из меню приложений.".to_string()
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_input(mut output: impl Write, metadata: &[u8], bytes: &[u8]) -> io::Result<()> {
|
||||
if metadata.len() > MAX_METADATA_BYTES || bytes.len() > MAX_ARTIFACT_BYTES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"update exceeds input limit",
|
||||
));
|
||||
}
|
||||
output.write_all(INPUT_MAGIC)?;
|
||||
output.write_all(&(metadata.len() as u64).to_be_bytes())?;
|
||||
output.write_all(metadata)?;
|
||||
output.write_all(&(bytes.len() as u64).to_be_bytes())?;
|
||||
output.write_all(bytes)
|
||||
}
|
||||
|
||||
fn read_part(input: &mut impl Read, maximum: usize) -> io::Result<Vec<u8>> {
|
||||
let mut length = [0_u8; 8];
|
||||
input.read_exact(&mut length)?;
|
||||
let length = u64::from_be_bytes(length);
|
||||
if length == 0 || length > maximum as u64 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"invalid update input length",
|
||||
));
|
||||
}
|
||||
let mut bytes = vec![0; length as usize];
|
||||
input.read_exact(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn read_input(mut input: impl Read) -> io::Result<(Value, Vec<u8>)> {
|
||||
let mut magic = [0_u8; 8];
|
||||
input.read_exact(&mut magic)?;
|
||||
if &magic != INPUT_MAGIC {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"invalid protocol",
|
||||
));
|
||||
}
|
||||
let metadata = read_part(&mut input, MAX_METADATA_BYTES)?;
|
||||
let bytes = read_part(&mut input, MAX_ARTIFACT_BYTES)?;
|
||||
let mut trailing = [0_u8];
|
||||
if input.read(&mut trailing)? != 0 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "trailing input"));
|
||||
}
|
||||
let raw = serde_json::from_slice(&metadata)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid metadata"))?;
|
||||
Ok((raw, bytes))
|
||||
}
|
||||
|
||||
fn exit_message(code: Option<i32>) -> String {
|
||||
match code {
|
||||
Some(0) => "",
|
||||
Some(126) => "Установка отменена в системном окне. Текущая версия лаунчера сохранена.",
|
||||
Some(127) => "Система не разрешила установку. Подтвердите права администратора в системном окне; при его отсутствии проверьте PolicyKit.",
|
||||
Some(REJECTED) => "Системная проверка подписи или версии deb не пройдена. Установка отменена.",
|
||||
Some(LOCKED) => "Пакетный менеджер занят другой установкой. Дождитесь её завершения и нажмите «Обновить» ещё раз.",
|
||||
Some(INVALID_HOST) => "Системная установка ShaCraft не подтверждена. Установите новый deb вручную с shacraft.ru/help#launcher.",
|
||||
_ => "Пакетный менеджер не завершил установку. Проверьте состояние пакетов в системе и повторите попытку; при необходимости установите deb вручную.",
|
||||
}.to_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn install(update: &Update, bytes: &[u8]) -> Result<(), String> {
|
||||
if !installed_binary_supported() {
|
||||
return Err(exit_message(Some(INVALID_HOST)));
|
||||
}
|
||||
if let Some(reason) = unsupported_reason() {
|
||||
return Err(reason);
|
||||
}
|
||||
let metadata =
|
||||
serde_json::to_vec(&update.raw_json).map_err(|_| exit_message(Some(REJECTED)))?;
|
||||
let mut child = Command::new(PKEXEC)
|
||||
.args(["--disable-internal-agent", BINARY, HELPER_FLAG])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|_| exit_message(Some(127)))?;
|
||||
// Always wait even on EPIPE: declining the system dialog closes stdin, and
|
||||
// its exit status is the useful cancellation result, not "broken pipe".
|
||||
let write_result = write_input(
|
||||
child.stdin.take().expect("piped helper input"),
|
||||
&metadata,
|
||||
bytes,
|
||||
);
|
||||
let status = child.wait().map_err(|_| exit_message(None))?;
|
||||
if status.success() && write_result.is_ok() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(exit_message(status.code().filter(|code| *code != 0)))
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_deb_release(raw: &Value, bytes: &[u8], key: &str, installed: &str) -> Result<String, ()> {
|
||||
let metadata = updater::verified_metadata(raw, key).map_err(|_| ())?;
|
||||
if !updater::newer_version(&metadata, installed).map_err(|_| ())? {
|
||||
return Err(());
|
||||
}
|
||||
let version = metadata["version"].as_str().ok_or(())?;
|
||||
let target = format!("linux-{}-deb", std::env::consts::ARCH);
|
||||
let artifact = metadata["platforms"].get(&target).ok_or(())?;
|
||||
let url = Url::parse(artifact["url"].as_str().ok_or(())?).map_err(|_| ())?;
|
||||
updater::validate_download_url(&url, version, InstallationKind::Deb).map_err(|_| ())?;
|
||||
updater::verify_signature(bytes, artifact["signature"].as_str().ok_or(())?, key)
|
||||
.map_err(|_| ())?;
|
||||
Ok(version.to_owned())
|
||||
}
|
||||
|
||||
fn valid_package_fields(output: &[u8], version: &str) -> bool {
|
||||
std::str::from_utf8(output)
|
||||
.is_ok_and(|text| text == format!("{PACKAGE}\n{version}\n{}\n", architecture()))
|
||||
}
|
||||
|
||||
fn lock_error(stderr: &[u8]) -> bool {
|
||||
let text = String::from_utf8_lossy(stderr).to_ascii_lowercase();
|
||||
(text.contains("lock")
|
||||
&& (text.contains("locked")
|
||||
|| text.contains("another process")
|
||||
|| text.contains("resource temporarily unavailable")
|
||||
|| text.contains("unable to acquire")))
|
||||
|| text.contains("dpkg frontend lock was locked")
|
||||
}
|
||||
|
||||
fn embedded_key() -> Result<String, ()> {
|
||||
let config: Value = serde_json::from_str(include_str!("../tauri.conf.json")).map_err(|_| ())?;
|
||||
config["plugins"]["updater"]["pubkey"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.ok_or(())
|
||||
}
|
||||
|
||||
fn run_helper() -> Result<(), i32> {
|
||||
// pkexec cleans the environment before executing this root-owned program.
|
||||
// Never initialize Tauri/GTK or network/account code in privileged mode.
|
||||
if unsafe { libc::geteuid() } != 0 || !installed_binary_supported() {
|
||||
return Err(INVALID_HOST);
|
||||
}
|
||||
let installed = installed_version().map_err(|_| INVALID_HOST)?;
|
||||
let (raw, bytes) = read_input(io::stdin().lock()).map_err(|_| REJECTED)?;
|
||||
let version = verify_deb_release(
|
||||
&raw,
|
||||
&bytes,
|
||||
&embedded_key().map_err(|_| REJECTED)?,
|
||||
&installed,
|
||||
)
|
||||
.map_err(|_| REJECTED)?;
|
||||
// No untrusted filesystem object crosses the privilege boundary. This
|
||||
// directory is created by root, mode 0700, after all signature checks.
|
||||
if !trusted_root_path(Path::new("/var"), false)
|
||||
|| !safe_sticky_temporary_parent(Path::new("/var/tmp"))
|
||||
{
|
||||
return Err(INVALID_HOST);
|
||||
}
|
||||
let temp = tempfile::Builder::new()
|
||||
.prefix("shacraft-update-")
|
||||
.tempdir_in("/var/tmp")
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700))
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
let package = temp.path().join("release.deb");
|
||||
let mut output = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&package)
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
output
|
||||
.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
output
|
||||
.write_all(&bytes)
|
||||
.and_then(|_| output.sync_all())
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
drop(output);
|
||||
let fields = capture(
|
||||
fixed_command(DEB)
|
||||
.arg("--show")
|
||||
.arg("--showformat=${Package}\n${Version}\n${Architecture}\n")
|
||||
.arg(&package),
|
||||
)
|
||||
.map_err(|_| REJECTED)?;
|
||||
if !fields.status.success() || !valid_package_fields(&fields.stdout, &version) {
|
||||
return Err(REJECTED);
|
||||
}
|
||||
// Check again immediately before mutation: another updater might have
|
||||
// installed the release while the authentication dialog was open.
|
||||
if !updater::newer_version(
|
||||
&serde_json::json!({"version": version}),
|
||||
&installed_version().map_err(|_| INVALID_HOST)?,
|
||||
)
|
||||
.map_err(|_| REJECTED)?
|
||||
{
|
||||
return Err(REJECTED);
|
||||
}
|
||||
let result = capture(
|
||||
fixed_command(DPKG)
|
||||
.args(["--refuse-downgrade", "--install"])
|
||||
.arg(&package),
|
||||
)
|
||||
.map_err(|_| INSTALL_FAILED)?;
|
||||
if !result.status.success() {
|
||||
return Err(if lock_error(&result.stderr) {
|
||||
LOCKED
|
||||
} else {
|
||||
INSTALL_FAILED
|
||||
});
|
||||
}
|
||||
if installed_version().map_err(|_| INSTALL_FAILED)? != version {
|
||||
return Err(INSTALL_FAILED);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The special flag is never registered as IPC and does not accept filenames.
|
||||
/// Even manually invoking it cannot bypass signatures, package identity or
|
||||
/// privilege checks. Errors intentionally print no package/metadata contents.
|
||||
pub(crate) fn run_helper_if_requested() -> Option<i32> {
|
||||
let arguments = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
if !arguments.iter().any(|argument| argument == HELPER_FLAG) {
|
||||
return None;
|
||||
}
|
||||
if arguments.len() != 1 || arguments[0] != HELPER_FLAG {
|
||||
return Some(REJECTED);
|
||||
}
|
||||
Some(match run_helper() {
|
||||
Ok(()) => 0,
|
||||
Err(code) => code,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn framed_input_rejects_oversized_truncated_and_trailing_data() {
|
||||
let mut bytes = Vec::new();
|
||||
write_input(&mut bytes, b"{}", b"package").unwrap();
|
||||
let (metadata, package) = read_input(&bytes[..]).unwrap();
|
||||
assert_eq!(metadata, serde_json::json!({}));
|
||||
assert_eq!(package, b"package");
|
||||
assert!(read_input(&bytes[..bytes.len() - 1]).is_err());
|
||||
bytes.push(0);
|
||||
assert!(read_input(&bytes[..]).is_err());
|
||||
let mut oversized = INPUT_MAGIC.to_vec();
|
||||
oversized.extend_from_slice(&u64::MAX.to_be_bytes());
|
||||
assert!(read_input(&oversized[..]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_identity_version_architecture_are_exact() {
|
||||
let good = format!("{PACKAGE}\n0.1.4\n{}\n", architecture());
|
||||
assert!(valid_package_fields(good.as_bytes(), "0.1.4"));
|
||||
for wrong in [
|
||||
good.replace(PACKAGE, "another-package"),
|
||||
good.replace("0.1.4", "0.1.5"),
|
||||
good.replace(architecture(), "all"),
|
||||
format!("{good}extra\n"),
|
||||
] {
|
||||
assert!(!valid_package_fields(wrong.as_bytes(), "0.1.4"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_authorization_and_package_lock_remain_distinct() {
|
||||
assert!(exit_message(Some(126)).contains("отменена"));
|
||||
assert!(exit_message(Some(127)).contains("не разрешила"));
|
||||
assert!(exit_message(Some(LOCKED)).contains("занят"));
|
||||
assert!(lock_error(
|
||||
b"dpkg: error: dpkg frontend lock was locked by another process"
|
||||
));
|
||||
assert!(!lock_error(
|
||||
b"dpkg: dependency problems prevent configuration"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privileged_path_rejects_user_owned_files_symlinks_and_relative_paths() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let file = directory.path().join("launcher");
|
||||
fs::write(&file, b"file").unwrap();
|
||||
fs::set_permissions(&file, fs::Permissions::from_mode(0o777)).unwrap();
|
||||
assert!(!trusted_root_path(&file, true));
|
||||
let link = directory.path().join("link");
|
||||
std::os::unix::fs::symlink("/usr/bin/dpkg", &link).unwrap();
|
||||
assert!(!trusted_root_path(&link, true));
|
||||
assert!(!trusted_root_path(Path::new("usr/bin/dpkg"), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_verification_does_not_accept_legacy_appimage_as_deb() {
|
||||
let fixture: Value =
|
||||
serde_json::from_str(include_str!("../tests/fixtures/updater-signed.json")).unwrap();
|
||||
assert!(verify_deb_release(
|
||||
&fixture["metadata"],
|
||||
fixture["artifactText"].as_str().unwrap().as_bytes(),
|
||||
fixture["publicKey"].as_str().unwrap(),
|
||||
"0.0.0"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspection_timeout_kills_descendants_holding_output_pipes() {
|
||||
let start = Instant::now();
|
||||
let result = capture_with_deadline(
|
||||
Command::new("/bin/sh").args(["-c", "sleep 30 & exit 0"]),
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
assert!(matches!(result, Err(error) if error.kind() == io::ErrorKind::TimedOut));
|
||||
assert!(start.elapsed() < Duration::from_secs(3));
|
||||
let output = capture(fixed_command(DEB).arg("--version")).unwrap();
|
||||
assert!(output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("Debian"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_output_is_bounded_and_fully_drained() {
|
||||
let input = vec![b'x'; OUTPUT_LIMIT * 4];
|
||||
assert_eq!(drain_capped(input.as_slice()).unwrap().len(), OUTPUT_LIMIT);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
mod admission;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod deb_updater;
|
||||
mod download;
|
||||
mod java;
|
||||
mod launch;
|
||||
@@ -20,6 +22,10 @@ mod commands;
|
||||
mod operations;
|
||||
|
||||
pub fn run() {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(code) = deb_updater::run_helper_if_requested() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
use tauri::Manager;
|
||||
tauri::Builder::default()
|
||||
.manage(operations::LauncherOperations::default())
|
||||
|
||||
+160
-34
@@ -15,13 +15,21 @@ use tauri_plugin_updater::{Update, UpdaterBuilder, UpdaterExt};
|
||||
use url::Url;
|
||||
|
||||
pub(crate) const UPDATE_ENDPOINT: &str = "https://shacraft.ru/launcher/updates/stable.json";
|
||||
const MAX_METADATA_BYTES: usize = 192 * 1024;
|
||||
pub(crate) const MAX_METADATA_BYTES: usize = 192 * 1024;
|
||||
const MAX_PAYLOAD_BYTES: usize = 64 * 1024;
|
||||
const MAX_ARTIFACT_BYTES: usize = 256 * 1024 * 1024;
|
||||
pub(crate) const MAX_ARTIFACT_BYTES: usize = 256 * 1024 * 1024;
|
||||
const BAD_METADATA: &str =
|
||||
"Не удалось подтвердить подлинность сведений об обновлении. Повторите проверку позже.";
|
||||
const BAD_SIGNATURE: &str = "Подпись обновления не прошла проверку. Установка отменена.";
|
||||
|
||||
#[derive(Clone, Copy, Serialize, PartialEq, Eq, Debug)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum InstallationKind {
|
||||
Appimage,
|
||||
Deb,
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum Stage {
|
||||
@@ -53,6 +61,7 @@ pub(crate) struct LauncherUpdater {
|
||||
pub(crate) struct UpdateStatus {
|
||||
pub current_version: String,
|
||||
pub supported: bool,
|
||||
pub installation_kind: InstallationKind,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
pub stage: Stage,
|
||||
@@ -81,6 +90,7 @@ impl LauncherUpdater {
|
||||
Ok(UpdateStatus {
|
||||
current_version: app.package_info().version.to_string(),
|
||||
supported: reason.is_none(),
|
||||
installation_kind: installation_kind(app),
|
||||
reason,
|
||||
stage: state.stage,
|
||||
version: state
|
||||
@@ -103,28 +113,39 @@ impl LauncherUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn unsupported_reason<R: Runtime>(app: &AppHandle<R>) -> Option<String> {
|
||||
pub(crate) fn installation_kind<R: Runtime>(app: &AppHandle<R>) -> InstallationKind {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let env = app.env();
|
||||
let valid = match (
|
||||
if let (Some(image), Some(directory), Ok(executable)) = (
|
||||
env.appimage.as_ref(),
|
||||
env.appdir.as_ref(),
|
||||
std::env::current_exe(),
|
||||
) {
|
||||
(Some(image), Some(directory), Ok(executable)) => linux_appimage_supported(
|
||||
std::path::Path::new(image),
|
||||
std::path::Path::new(directory),
|
||||
&executable,
|
||||
),
|
||||
_ => false,
|
||||
};
|
||||
if !valid {
|
||||
return Some("Автообновление в Linux доступно в AppImage. Установите AppImage с shacraft.ru и запускайте его.".into());
|
||||
if linux_appimage_supported(image.as_ref(), directory.as_ref(), &executable) {
|
||||
return InstallationKind::Appimage;
|
||||
}
|
||||
}
|
||||
if crate::deb_updater::installed_binary_supported() {
|
||||
return InstallationKind::Deb;
|
||||
}
|
||||
}
|
||||
let _ = app;
|
||||
InstallationKind::Other
|
||||
}
|
||||
|
||||
pub(crate) fn unsupported_reason<R: Runtime>(app: &AppHandle<R>) -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
match installation_kind(app) {
|
||||
InstallationKind::Appimage => return None,
|
||||
InstallationKind::Deb => return crate::deb_updater::unsupported_reason(),
|
||||
InstallationKind::Other => return Some("Для автообновления установите deb-пакет или запустите AppImage с shacraft.ru/help#launcher.".into()),
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = app;
|
||||
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
|
||||
return Some("Для этой платформы доступна только ручная установка обновлений.".into());
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
None
|
||||
}
|
||||
|
||||
@@ -169,6 +190,9 @@ pub(crate) fn installation_path<R: Runtime>(
|
||||
) -> Result<Option<std::path::PathBuf>, String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if installation_kind(app) == InstallationKind::Deb {
|
||||
return Ok(None);
|
||||
}
|
||||
let image = app
|
||||
.env()
|
||||
.appimage
|
||||
@@ -250,7 +274,7 @@ async fn bounded_response(
|
||||
/// Same Minisign format and verification semantics as Tauri's updater. The
|
||||
/// signed metadata and artifact each need a valid signature under the embedded
|
||||
/// release key. A signed old artifact cannot be labelled as a new version.
|
||||
fn verify_signature(
|
||||
pub(crate) fn verify_signature(
|
||||
bytes: &[u8],
|
||||
encoded_signature: &str,
|
||||
encoded_key: &str,
|
||||
@@ -271,7 +295,7 @@ fn verify_signature(
|
||||
.map_err(|_| BAD_SIGNATURE.into())
|
||||
}
|
||||
|
||||
fn verified_metadata(raw: &Value, key: &str) -> Result<Value, String> {
|
||||
pub(crate) fn verified_metadata(raw: &Value, key: &str) -> Result<Value, String> {
|
||||
let object = raw.as_object().ok_or(BAD_METADATA)?;
|
||||
if object.len() != 6 {
|
||||
return Err(BAD_METADATA.into());
|
||||
@@ -305,7 +329,7 @@ fn verified_metadata(raw: &Value, key: &str) -> Result<Value, String> {
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn newer_version(metadata: &Value, current: &str) -> Result<bool, String> {
|
||||
pub(crate) fn newer_version(metadata: &Value, current: &str) -> Result<bool, String> {
|
||||
let announced = metadata
|
||||
.get("version")
|
||||
.and_then(Value::as_str)
|
||||
@@ -319,7 +343,7 @@ fn newer_version(metadata: &Value, current: &str) -> Result<bool, String> {
|
||||
Ok(version > current)
|
||||
}
|
||||
|
||||
fn require_platform(metadata: &Value) -> Result<(), String> {
|
||||
fn require_platform(metadata: &Value, kind: InstallationKind) -> Result<String, String> {
|
||||
let os = if cfg!(target_os = "macos") {
|
||||
"darwin"
|
||||
} else {
|
||||
@@ -330,17 +354,34 @@ fn require_platform(metadata: &Value) -> Result<(), String> {
|
||||
.get("platforms")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or(BAD_METADATA)?;
|
||||
let available = platforms.contains_key(&target)
|
||||
|| ["appimage", "nsis", "msi", "app"]
|
||||
#[cfg(target_os = "linux")]
|
||||
let targets = match kind {
|
||||
InstallationKind::Deb => vec![format!("{target}-deb")],
|
||||
InstallationKind::Appimage => vec![format!("{target}-appimage"), target],
|
||||
InstallationKind::Other => {
|
||||
return Err("Формат установленного лаунчера не поддерживает обновление.".into())
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let targets = {
|
||||
let _ = kind;
|
||||
["nsis", "msi", "app"]
|
||||
.iter()
|
||||
.any(|bundle| platforms.contains_key(&format!("{target}-{bundle}")));
|
||||
if !available {
|
||||
return Err("Обновление для вашей платформы пока не опубликовано.".into());
|
||||
}
|
||||
Ok(())
|
||||
.map(|bundle| format!("{target}-{bundle}"))
|
||||
.chain(std::iter::once(target))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
targets
|
||||
.into_iter()
|
||||
.find(|target| platforms.contains_key(target))
|
||||
.ok_or_else(|| "Обновление для вашего формата установки пока не опубликовано.".into())
|
||||
}
|
||||
|
||||
fn validate_download_url(url: &Url, version: &str) -> Result<(), String> {
|
||||
pub(crate) fn validate_download_url(
|
||||
url: &Url,
|
||||
version: &str,
|
||||
kind: InstallationKind,
|
||||
) -> Result<(), String> {
|
||||
let prefix = format!("/downloads/shacraft-launcher/{version}/");
|
||||
let filename = url.path().strip_prefix(&prefix).ok_or(BAD_METADATA)?;
|
||||
if url.scheme() != "https"
|
||||
@@ -359,7 +400,11 @@ fn validate_download_url(url: &Url, version: &str) -> Result<(), String> {
|
||||
return Err(BAD_METADATA.into());
|
||||
}
|
||||
let correct_extension = if cfg!(target_os = "linux") {
|
||||
filename.ends_with(".AppImage")
|
||||
match kind {
|
||||
InstallationKind::Appimage => filename.ends_with(".AppImage"),
|
||||
InstallationKind::Deb => filename.ends_with(".deb"),
|
||||
InstallationKind::Other => false,
|
||||
}
|
||||
} else if cfg!(target_os = "macos") {
|
||||
filename.ends_with(".app.tar.gz")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
@@ -373,6 +418,18 @@ fn validate_download_url(url: &Url, version: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn candidate_kind(update: &Update) -> InstallationKind {
|
||||
if cfg!(target_os = "linux") {
|
||||
if update.target == format!("linux-{}-deb", std::env::consts::ARCH) {
|
||||
InstallationKind::Deb
|
||||
} else {
|
||||
InstallationKind::Appimage
|
||||
}
|
||||
} else {
|
||||
InstallationKind::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch and authenticate a bounded static manifest before asking the vendored
|
||||
/// upstream plugin's small offline constructor to create an Update. Its normal
|
||||
/// HTTP check is intentionally unused because it buffers unbounded JSON.
|
||||
@@ -380,6 +437,7 @@ pub(crate) async fn check_candidate(
|
||||
builder: UpdaterBuilder,
|
||||
key: &str,
|
||||
current: &str,
|
||||
kind: InstallationKind,
|
||||
) -> Result<Option<Update>, String> {
|
||||
let response = http_client(Duration::from_secs(20))?
|
||||
.get(UPDATE_ENDPOINT)
|
||||
@@ -391,10 +449,14 @@ pub(crate) async fn check_candidate(
|
||||
let bytes = bounded_response(response, MAX_METADATA_BYTES, |_, _| {}).await?;
|
||||
let raw: Value = serde_json::from_slice(&bytes).map_err(|_| BAD_METADATA)?;
|
||||
let metadata = verified_metadata(&raw, key)?;
|
||||
require_platform(&metadata)?;
|
||||
let target = require_platform(&metadata, kind)?;
|
||||
if !newer_version(&metadata, current)? {
|
||||
return Ok(None);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
let builder = builder.target(target);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = target;
|
||||
let update = builder
|
||||
.build()
|
||||
.map_err(updater_error)?
|
||||
@@ -409,7 +471,11 @@ pub(crate) async fn check_candidate(
|
||||
}
|
||||
// Retain the exact signed envelope with the native-only candidate.
|
||||
verified_metadata(&update.raw_json, key)?;
|
||||
validate_download_url(&update.download_url, &update.version)?;
|
||||
validate_download_url(
|
||||
&update.download_url,
|
||||
&update.version,
|
||||
candidate_kind(&update),
|
||||
)?;
|
||||
Ok(Some(update))
|
||||
}
|
||||
|
||||
@@ -418,7 +484,11 @@ pub(crate) async fn download_verified(
|
||||
key: &str,
|
||||
progress: impl FnMut(u64, Option<u64>),
|
||||
) -> Result<Vec<u8>, String> {
|
||||
validate_download_url(&update.download_url, &update.version)?;
|
||||
validate_download_url(
|
||||
&update.download_url,
|
||||
&update.version,
|
||||
candidate_kind(update),
|
||||
)?;
|
||||
verified_metadata(&update.raw_json, key)?;
|
||||
let response = http_client(Duration::from_secs(600))?
|
||||
.get(update.download_url.clone())
|
||||
@@ -441,10 +511,17 @@ pub(crate) fn install_verified(
|
||||
key: &str,
|
||||
destination: Option<&std::path::Path>,
|
||||
) -> Result<(), String> {
|
||||
validate_download_url(&update.download_url, &update.version)?;
|
||||
validate_download_url(
|
||||
&update.download_url,
|
||||
&update.version,
|
||||
candidate_kind(update),
|
||||
)?;
|
||||
verify_signature(bytes, &update.signature, key)?;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if candidate_kind(update) == InstallationKind::Deb {
|
||||
return crate::deb_updater::install(update, bytes);
|
||||
}
|
||||
let destination = destination.ok_or("Файл AppImage недоступен.")?;
|
||||
install_appimage_atomic(destination, bytes).map_err(|_| {
|
||||
"Не удалось заменить AppImage. Проверьте свободное место и права на папку лаунчера."
|
||||
@@ -522,7 +599,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn download_policy_pins_origin_version_plain_path_and_package_type() {
|
||||
assert!(validate_download_url(&Url::parse(artifact_url()).unwrap(), "0.2.0").is_ok());
|
||||
assert!(validate_download_url(
|
||||
&Url::parse(artifact_url()).unwrap(),
|
||||
"0.2.0",
|
||||
InstallationKind::Appimage
|
||||
)
|
||||
.is_ok());
|
||||
for value in [
|
||||
artifact_url().replace("https:", "http:"),
|
||||
artifact_url().replace("shacraft.ru/", "evil.example/"),
|
||||
@@ -536,12 +618,52 @@ mod tests {
|
||||
format!("{}.sh", artifact_url()),
|
||||
] {
|
||||
assert!(
|
||||
validate_download_url(&Url::parse(&value).unwrap(), "0.2.0").is_err(),
|
||||
validate_download_url(
|
||||
&Url::parse(&value).unwrap(),
|
||||
"0.2.0",
|
||||
InstallationKind::Appimage
|
||||
)
|
||||
.is_err(),
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_selects_package_family_without_deb_fallback() {
|
||||
let base = format!("linux-{}", std::env::consts::ARCH);
|
||||
let legacy = serde_json::json!({"platforms": {base.clone(): {}}});
|
||||
assert_eq!(
|
||||
require_platform(&legacy, InstallationKind::Appimage).unwrap(),
|
||||
base
|
||||
);
|
||||
assert!(require_platform(&legacy, InstallationKind::Deb).is_err());
|
||||
let exact_image = format!("{base}-appimage");
|
||||
let exact_deb = format!("{base}-deb");
|
||||
let all = serde_json::json!({"platforms": {base.clone(): {}, exact_image.clone(): {}, exact_deb.clone(): {}}});
|
||||
assert_eq!(
|
||||
require_platform(&all, InstallationKind::Appimage).unwrap(),
|
||||
exact_image
|
||||
);
|
||||
assert_eq!(
|
||||
require_platform(&all, InstallationKind::Deb).unwrap(),
|
||||
exact_deb
|
||||
);
|
||||
let deb = Url::parse(
|
||||
"https://shacraft.ru/downloads/shacraft-launcher/0.2.0/ShaCraft_0.2.0_amd64.deb",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(validate_download_url(&deb, "0.2.0", InstallationKind::Deb).is_ok());
|
||||
assert!(validate_download_url(&deb, "0.2.0", InstallationKind::Appimage).is_err());
|
||||
assert!(validate_download_url(
|
||||
&Url::parse(artifact_url()).unwrap(),
|
||||
"0.2.0",
|
||||
InstallationKind::Deb
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_channel_never_downgrades_or_installs_equal_aliases() {
|
||||
assert!(newer_version(&serde_json::json!({"version":"0.2.0"}), "0.1.3").unwrap());
|
||||
@@ -621,7 +743,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unavailable_platform_is_not_reported_as_latest() {
|
||||
assert!(require_platform(&serde_json::json!({"platforms":{}})).is_err());
|
||||
assert!(require_platform(
|
||||
&serde_json::json!({"platforms":{}}),
|
||||
InstallationKind::Appimage
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -698,7 +824,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.executable_path(&destination);
|
||||
tauri::async_runtime::block_on(async {
|
||||
let update = check_candidate(builder, &key, "0.1.2")
|
||||
let update = check_candidate(builder, &key, "0.1.2", InstallationKind::Appimage)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("newer published version");
|
||||
|
||||
Reference in New Issue
Block a user