fix(tauri): bind elevated scripts to catalog authority
Preserve required UAC elevation for game_setup.cmd, game_start.cmd, and server_start.cmd through a fixed-role elevated launcher worker. The worker reloads and matches embedded catalog authority, verifies the exact script from a no-follow locked handle, resolves System32 cmd.exe, and transfers path locks into the command process. Setup still waits for completion; game and server return after the verified handoff while their command process retains the locks. Unmanifested, changed, reparse-backed, markerless, and streamed-only scripts fail closed. Test Plan: - just test - just clippy - just frontend-test - just build-fixture - nine Linux-visible authority/parser/digest tests - Windows-only lock-transfer test added but not run (no Windows target/runtime available) - git diff --check
This commit is contained in:
1 parent
42cf98ecec
commit
67ea355599
6 files changed
+1491
-170
No files matched your search
@@ -16,6 +16,8 @@ use std::{
|
||||
|
||||
use eyre::bail;
|
||||
use lanspread_compat::catalog_bundle::{LoadedCatalog, load_catalog_bundle};
|
||||
#[cfg(target_os = "windows")]
|
||||
use lanspread_db::content_manifest::CatalogContentManifest;
|
||||
use lanspread_db::{
|
||||
content_manifest::CatalogBundle,
|
||||
db::{Availability, Game, GameDB},
|
||||
@@ -73,6 +75,13 @@ use tracing_subscriber::{
|
||||
};
|
||||
|
||||
mod sharing_policy;
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
mod windows_launch;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn elevated_script_worker_exit_code() -> Option<i32> {
|
||||
windows_launch::elevated_worker_exit_code()
|
||||
}
|
||||
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
|
||||
@@ -1495,32 +1504,6 @@ fn sanitize_username(username: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String {
|
||||
script_params_with_mode("/c", script_path, id, settings)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn server_script_params(script_path: &Path, id: &str, settings: &LaunchSettings) -> String {
|
||||
script_params_with_mode("/k", script_path, id, settings)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn script_params_with_mode(
|
||||
cmd_mode: &str,
|
||||
script_path: &Path,
|
||||
id: &str,
|
||||
settings: &LaunchSettings,
|
||||
) -> String {
|
||||
format!(
|
||||
r#"/d /s {cmd_mode} ""{}" "local" "{}" "{}" "{}"""#,
|
||||
script_path.display(),
|
||||
id,
|
||||
settings.language,
|
||||
settings.username,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<usize> {
|
||||
let _app_invoke = enter_app_invoke(state.inner())?;
|
||||
@@ -1564,36 +1547,6 @@ async fn get_game_thumbnail(
|
||||
Ok(format!("data:image/jpeg;base64,{base64_data}"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_as_admin_detached(
|
||||
file: &str,
|
||||
params: &str,
|
||||
dir: &str,
|
||||
show_cmd: windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD,
|
||||
) -> bool {
|
||||
use std::{ffi::OsStr, os::windows::ffi::OsStrExt};
|
||||
|
||||
use windows::{Win32::UI::Shell::ShellExecuteW, core::PCWSTR};
|
||||
|
||||
let file_wide: Vec<u16> = OsStr::new(file).encode_wide().chain(Some(0)).collect();
|
||||
let params_wide: Vec<u16> = OsStr::new(params).encode_wide().chain(Some(0)).collect();
|
||||
let dir_wide: Vec<u16> = OsStr::new(dir).encode_wide().chain(Some(0)).collect();
|
||||
let runas_wide: Vec<u16> = OsStr::new("runas").encode_wide().chain(Some(0)).collect();
|
||||
|
||||
let result = unsafe {
|
||||
ShellExecuteW(
|
||||
None,
|
||||
PCWSTR::from_raw(runas_wide.as_ptr()),
|
||||
PCWSTR::from_raw(file_wide.as_ptr()),
|
||||
PCWSTR::from_raw(params_wide.as_ptr()),
|
||||
PCWSTR::from_raw(dir_wide.as_ptr()),
|
||||
show_cmd,
|
||||
)
|
||||
};
|
||||
|
||||
(result.0 as usize) > 32 // Success if greater than 32
|
||||
}
|
||||
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
fn setup_process_exit_succeeded(exit_code: u32) -> bool {
|
||||
exit_code == 0
|
||||
@@ -1695,9 +1648,9 @@ where
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_as_admin_and_wait(
|
||||
file: &str,
|
||||
params: &str,
|
||||
dir: &str,
|
||||
file: &std::ffi::OsStr,
|
||||
params: &std::ffi::OsStr,
|
||||
dir: &std::ffi::OsStr,
|
||||
show_cmd: windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD,
|
||||
) -> Result<(), String> {
|
||||
use std::{ffi::OsStr, os::windows::ffi::OsStrExt};
|
||||
@@ -1812,18 +1765,9 @@ fn run_as_admin_and_wait(
|
||||
}
|
||||
}
|
||||
|
||||
let file_wide = OsStr::new(file)
|
||||
.encode_wide()
|
||||
.chain(Some(0))
|
||||
.collect::<Vec<_>>();
|
||||
let params_wide = OsStr::new(params)
|
||||
.encode_wide()
|
||||
.chain(Some(0))
|
||||
.collect::<Vec<_>>();
|
||||
let dir_wide = OsStr::new(dir)
|
||||
.encode_wide()
|
||||
.chain(Some(0))
|
||||
.collect::<Vec<_>>();
|
||||
let file_wide = file.encode_wide().chain(Some(0)).collect::<Vec<_>>();
|
||||
let params_wide = params.encode_wide().chain(Some(0)).collect::<Vec<_>>();
|
||||
let dir_wide = dir.encode_wide().chain(Some(0)).collect::<Vec<_>>();
|
||||
let runas_wide = OsStr::new("runas")
|
||||
.encode_wide()
|
||||
.chain(Some(0))
|
||||
@@ -1876,6 +1820,38 @@ fn run_as_admin_and_wait(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn catalog_manifest_for_elevated_launch(
|
||||
state: &LanSpreadState,
|
||||
id: &str,
|
||||
) -> Result<Arc<CatalogContentManifest>, String> {
|
||||
let catalog = state
|
||||
.catalog_bundle
|
||||
.get()
|
||||
.cloned()
|
||||
.ok_or_else(|| "catalog authority is not initialized".to_owned())?;
|
||||
let game_id = id.to_owned();
|
||||
let error_id = game_id.clone();
|
||||
scoped_blocking(move || catalog.manifest(&game_id))
|
||||
.map_err(|error| format!("failed to load catalog manifest for {error_id}: {error:#}"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn elevated_worker_program() -> Result<(PathBuf, PathBuf), String> {
|
||||
let executable = std::env::current_exe()
|
||||
.map_err(|error| format!("failed to resolve launcher executable: {error}"))?;
|
||||
let working_directory = executable
|
||||
.parent()
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"launcher executable has no parent directory: {}",
|
||||
executable.display()
|
||||
)
|
||||
})?
|
||||
.to_path_buf();
|
||||
Ok((executable, working_directory))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn run_game_windows(
|
||||
id: String,
|
||||
@@ -1888,27 +1864,74 @@ async fn run_game_windows(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let _serial_game_directory = state.inner().app_invokes.serialize_peer_startup().await;
|
||||
let settings = launch_settings(&language, &username);
|
||||
let games_folder_lock = state.inner().games_folder.clone();
|
||||
let games_folder = {
|
||||
let guard = games_folder_lock.read().await;
|
||||
guard.clone()
|
||||
};
|
||||
|
||||
let games_folder = PathBuf::from(games_folder);
|
||||
let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone());
|
||||
if state
|
||||
.inner()
|
||||
.active_operations
|
||||
.read()
|
||||
.await
|
||||
.contains_key(&id)
|
||||
{
|
||||
log::warn!("Ignoring run request while a game operation is active: {id}");
|
||||
return Ok(());
|
||||
}
|
||||
if !games_folder.exists() {
|
||||
log::error!("games_folder {} does not exist", games_folder.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let game_path = games_folder.join(id.clone());
|
||||
|
||||
let game_setup_bin = game_path.join(GAME_SETUP_SCRIPT);
|
||||
let game_start_bin = game_path.join(GAME_START_SCRIPT);
|
||||
let installed = state
|
||||
.inner()
|
||||
.games
|
||||
.read()
|
||||
.await
|
||||
.get_game_by_id(&id)
|
||||
.is_some_and(|game| game.installed);
|
||||
if !installed {
|
||||
log::warn!("Ignoring run request for game without an installed catalog state: {id}");
|
||||
return Ok(());
|
||||
}
|
||||
let Some(state_dir) = state.inner().state_dir.get().cloned() else {
|
||||
log::error!("app state directory is not initialized; cannot run game");
|
||||
return Ok(());
|
||||
};
|
||||
let manifest = match catalog_manifest_for_elevated_launch(state.inner(), &id) {
|
||||
Ok(manifest) => manifest,
|
||||
Err(error) => {
|
||||
log::error!("Ignoring run request without catalog authority: {error}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let setup_authority = match windows_launch::catalog_script_authority(
|
||||
&manifest,
|
||||
windows_launch::ElevatedScriptRole::Setup,
|
||||
) {
|
||||
Ok(authority) => authority,
|
||||
Err(error) => {
|
||||
log::error!("Ignoring run request with invalid setup authority: {error}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let game_authority = match windows_launch::catalog_script_authority(
|
||||
&manifest,
|
||||
windows_launch::ElevatedScriptRole::Game,
|
||||
) {
|
||||
Ok(authority) => authority,
|
||||
Err(error) => {
|
||||
log::error!("Ignoring run request with invalid game authority: {error}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let (worker, worker_directory) = match elevated_worker_program() {
|
||||
Ok(worker) => worker,
|
||||
Err(error) => {
|
||||
log::error!("Cannot start elevated launch worker: {error}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let setup_done_file = match lanspread_peer::setup_done_path(&state_dir, &id) {
|
||||
Ok(path) => path,
|
||||
@@ -1917,22 +1940,28 @@ async fn run_game_windows(
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if !setup_done_file.exists() && game_setup_bin.exists() {
|
||||
if !local_install_is_present(&game_path) {
|
||||
log::warn!(
|
||||
"local install is missing for {}; skipping game_setup",
|
||||
game_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let setup_params = script_params(&game_setup_bin, &id, &settings);
|
||||
let game_dir = game_path.display().to_string();
|
||||
if !setup_done_file.exists()
|
||||
&& let Some(authority) = setup_authority
|
||||
{
|
||||
let setup_params = match windows_launch::worker_parameters(
|
||||
windows_launch::ElevatedScriptRole::Setup,
|
||||
&games_folder,
|
||||
&id,
|
||||
&settings.language,
|
||||
&settings.username,
|
||||
authority,
|
||||
) {
|
||||
Ok(parameters) => parameters,
|
||||
Err(error) => {
|
||||
log::error!("failed to prepare {GAME_SETUP_SCRIPT}: {error}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if let Err(err) = scoped_blocking(|| {
|
||||
run_as_admin_and_wait(
|
||||
"cmd.exe",
|
||||
&setup_params,
|
||||
&game_dir,
|
||||
worker.as_os_str(),
|
||||
std::ffi::OsStr::new(&setup_params),
|
||||
worker_directory.as_os_str(),
|
||||
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
||||
)
|
||||
}) {
|
||||
@@ -1959,18 +1988,33 @@ async fn run_game_windows(
|
||||
|
||||
apply_launch_settings(&state_dir, &game_path, &id, &language, &username);
|
||||
|
||||
if game_start_bin.exists() {
|
||||
// Game processes are intentionally user-owned: unlike setup, their
|
||||
// lifetime is not an install transaction or a launcher state boundary.
|
||||
let result = run_as_admin_detached(
|
||||
"cmd.exe",
|
||||
&script_params(&game_start_bin, &id, &settings),
|
||||
&game_path.display().to_string(),
|
||||
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
||||
);
|
||||
|
||||
if !result {
|
||||
log::error!("failed to run {GAME_START_SCRIPT}");
|
||||
if let Some(authority) = game_authority {
|
||||
let game_params = match windows_launch::worker_parameters(
|
||||
windows_launch::ElevatedScriptRole::Game,
|
||||
&games_folder,
|
||||
&id,
|
||||
&settings.language,
|
||||
&settings.username,
|
||||
authority,
|
||||
) {
|
||||
Ok(parameters) => parameters,
|
||||
Err(error) => {
|
||||
log::error!("failed to prepare {GAME_START_SCRIPT}: {error}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
// The elevated worker transfers the verified script and path locks to
|
||||
// cmd.exe before it reports success. The game remains free to outlive
|
||||
// both the worker and this launcher.
|
||||
if let Err(error) = scoped_blocking(|| {
|
||||
run_as_admin_and_wait(
|
||||
worker.as_os_str(),
|
||||
std::ffi::OsStr::new(&game_params),
|
||||
worker_directory.as_os_str(),
|
||||
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
||||
)
|
||||
}) {
|
||||
log::error!("failed to start {GAME_START_SCRIPT}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2035,28 +2079,83 @@ async fn start_server_windows(
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let _serial_game_directory = state.inner().app_invokes.serialize_peer_startup().await;
|
||||
let settings = launch_settings(&language, &username);
|
||||
let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone());
|
||||
if state
|
||||
.inner()
|
||||
.active_operations
|
||||
.read()
|
||||
.await
|
||||
.contains_key(&id)
|
||||
{
|
||||
log::warn!("Ignoring server start request while a game operation is active: {id}");
|
||||
return Ok(false);
|
||||
}
|
||||
if !games_folder.exists() {
|
||||
log::error!("games_folder {} does not exist", games_folder.display());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let game_path = games_folder.join(id.clone());
|
||||
if !local_install_is_present(&game_path) {
|
||||
log::warn!(
|
||||
"local install is missing for {}; skipping {SERVER_START_SCRIPT}",
|
||||
game_path.display()
|
||||
);
|
||||
let installed = state
|
||||
.inner()
|
||||
.games
|
||||
.read()
|
||||
.await
|
||||
.get_game_by_id(&id)
|
||||
.is_some_and(|game| game.installed);
|
||||
if !installed {
|
||||
log::warn!("Ignoring server start request without an installed catalog state: {id}");
|
||||
return Ok(false);
|
||||
}
|
||||
let manifest = match catalog_manifest_for_elevated_launch(state.inner(), &id) {
|
||||
Ok(manifest) => manifest,
|
||||
Err(error) => {
|
||||
log::error!("Ignoring server start request without catalog authority: {error}");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
let authority = match windows_launch::catalog_script_authority(
|
||||
&manifest,
|
||||
windows_launch::ElevatedScriptRole::Server,
|
||||
) {
|
||||
Ok(Some(authority)) => authority,
|
||||
Ok(None) => {
|
||||
log::warn!("Catalog does not provide {SERVER_START_SCRIPT} for {id}");
|
||||
return Ok(false);
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("Ignoring server start request with invalid authority: {error}");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
let (worker, worker_directory) = match elevated_worker_program() {
|
||||
Ok(worker) => worker,
|
||||
Err(error) => {
|
||||
log::error!("Cannot start elevated launch worker: {error}");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
let server_params = match windows_launch::worker_parameters(
|
||||
windows_launch::ElevatedScriptRole::Server,
|
||||
&games_folder,
|
||||
&id,
|
||||
&settings.language,
|
||||
&settings.username,
|
||||
authority,
|
||||
) {
|
||||
Ok(parameters) => parameters,
|
||||
Err(error) => {
|
||||
log::error!("failed to prepare {SERVER_START_SCRIPT}: {error}");
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
|
||||
let server_start_bin = game_path.join(SERVER_START_SCRIPT);
|
||||
if !server_start_bin.is_file() {
|
||||
if !game_path.is_dir() {
|
||||
log::warn!(
|
||||
"server start script is missing for {}: {}",
|
||||
id,
|
||||
server_start_bin.display()
|
||||
"Game directory disappeared before server launch: {}",
|
||||
game_path.display()
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
@@ -2067,19 +2166,21 @@ async fn start_server_windows(
|
||||
};
|
||||
apply_launch_settings(&state_dir, &game_path, &id, &language, &username);
|
||||
|
||||
// Hosted servers are intentionally user-owned and may outlive the launcher.
|
||||
let result = run_as_admin_detached(
|
||||
"cmd.exe",
|
||||
&server_script_params(&server_start_bin, &id, &settings),
|
||||
&game_path.display().to_string(),
|
||||
windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL,
|
||||
);
|
||||
|
||||
if !result {
|
||||
log::error!("failed to run {SERVER_START_SCRIPT}");
|
||||
// The worker transfers its verification locks to the hosted command shell,
|
||||
// which remains detached from, and may outlive, the launcher.
|
||||
let result = scoped_blocking(|| {
|
||||
run_as_admin_and_wait(
|
||||
worker.as_os_str(),
|
||||
std::ffi::OsStr::new(&server_params),
|
||||
worker_directory.as_os_str(),
|
||||
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
||||
)
|
||||
});
|
||||
if let Err(error) = &result {
|
||||
log::error!("failed to start {SERVER_START_SCRIPT}: {error}");
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
Ok(result.is_ok())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -2103,11 +2204,6 @@ async fn start_server(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn local_install_is_present(game_path: &Path) -> bool {
|
||||
game_path.join("local").is_dir()
|
||||
}
|
||||
|
||||
fn clear_local_game_state(game: &mut Game) {
|
||||
game.set_downloaded(false);
|
||||
game.installed = false;
|
||||
@@ -6220,41 +6316,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_params_use_common_argument_shape() {
|
||||
let start_params = script_params(
|
||||
Path::new("C:/Games/My Game")
|
||||
.join(GAME_START_SCRIPT)
|
||||
.as_path(),
|
||||
"my-game",
|
||||
&LaunchSettings {
|
||||
language: "en".to_string(),
|
||||
username: "Alice".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
start_params,
|
||||
r#"/d /s /c ""C:/Games/My Game/game_start.cmd" "local" "my-game" "en" "Alice"""#
|
||||
);
|
||||
|
||||
let server_params = server_script_params(
|
||||
Path::new("C:/Games/My Game")
|
||||
.join(SERVER_START_SCRIPT)
|
||||
.as_path(),
|
||||
"my-game",
|
||||
&LaunchSettings {
|
||||
language: "en".to_string(),
|
||||
username: "Alice".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
server_params,
|
||||
r#"/d /s /k ""C:/Games/My Game/server_start.cmd" "local" "my-game" "en" "Alice"""#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_host_capability_requires_installed_game_with_script() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
|
||||
Reference in new issue
Block a user