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:
Generated
+1
@@ -2247,6 +2247,7 @@ name = "lanspread-tauri-deno-ts"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
|
"blake3",
|
||||||
"cap-fs-ext",
|
"cap-fs-ext",
|
||||||
"cap-primitives",
|
"cap-primitives",
|
||||||
"eyre",
|
"eyre",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ lanspread-peer = { path = "../../lanspread-peer" }
|
|||||||
|
|
||||||
# external
|
# external
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
|
blake3 = { workspace = true }
|
||||||
cap-fs-ext = { workspace = true }
|
cap-fs-ext = { workspace = true }
|
||||||
cap-primitives = { workspace = true }
|
cap-primitives = { workspace = true }
|
||||||
eyre = { workspace = true }
|
eyre = { workspace = true }
|
||||||
@@ -53,7 +54,14 @@ tauri-build = { version = "2", features = [] }
|
|||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|
||||||
[target."cfg(windows)".dependencies]
|
[target."cfg(windows)".dependencies]
|
||||||
windows = { workspace = true, features = ["Win32_Storage_FileSystem"] }
|
windows = {
|
||||||
|
workspace = true,
|
||||||
|
features = [
|
||||||
|
"Win32_Security",
|
||||||
|
"Win32_Storage_FileSystem",
|
||||||
|
"Win32_System_SystemInformation",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
[lints.clippy]
|
[lints.clippy]
|
||||||
needless_pass_by_value = "allow"
|
needless_pass_by_value = "allow"
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
use std::{env, fs};
|
use std::{
|
||||||
|
env,
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
use build_support::catalog_gate::{
|
use build_support::catalog_gate::{
|
||||||
CatalogBuildMode,
|
CatalogBuildMode,
|
||||||
@@ -13,6 +17,9 @@ mod build_support {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FIXTURE_DEVELOPMENT_ENV: &str = "LANSPREAD_USE_FIXTURE_CATALOG";
|
const FIXTURE_DEVELOPMENT_ENV: &str = "LANSPREAD_USE_FIXTURE_CATALOG";
|
||||||
|
const CATALOG_CONTENT_INDEX_NAME: &str = "catalog-content-index-v1.jsonl";
|
||||||
|
const EMBEDDED_CATALOG_INDEX_NAME: &str = "embedded-catalog-content-index-v1.jsonl";
|
||||||
|
const FIXTURE_MANIFEST_ROOT: &str = "../../lanspread-peer-cli/catalogs/default/manifests";
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
println!("cargo:rerun-if-env-changed=TAURI_CONFIG");
|
println!("cargo:rerun-if-env-changed=TAURI_CONFIG");
|
||||||
@@ -29,9 +36,27 @@ fn main() {
|
|||||||
{
|
{
|
||||||
panic!("production catalog authority gate failed: {error}");
|
panic!("production catalog authority gate failed: {error}");
|
||||||
}
|
}
|
||||||
|
embed_catalog_content_index(mode).unwrap_or_else(|error| {
|
||||||
|
panic!("failed to embed catalog launch authority: {error}");
|
||||||
|
});
|
||||||
tauri_build::build();
|
tauri_build::build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn embed_catalog_content_index(mode: CatalogBuildMode) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let manifest_root = match mode {
|
||||||
|
CatalogBuildMode::FixtureDevelopment => Path::new(FIXTURE_MANIFEST_ROOT),
|
||||||
|
CatalogBuildMode::Production => Path::new("manifests"),
|
||||||
|
};
|
||||||
|
let source = manifest_root.join(CATALOG_CONTENT_INDEX_NAME);
|
||||||
|
println!("cargo:rerun-if-changed={}", source.display());
|
||||||
|
let bytes = fs::read(&source)?;
|
||||||
|
let out_dir =
|
||||||
|
env::var_os("OUT_DIR").ok_or_else(|| std::io::Error::other("OUT_DIR is not set"))?;
|
||||||
|
let output = PathBuf::from(out_dir).join(EMBEDDED_CATALOG_INDEX_NAME);
|
||||||
|
fs::write(output, bytes)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn catalog_build_mode() -> Result<CatalogBuildMode, Box<dyn std::error::Error>> {
|
fn catalog_build_mode() -> Result<CatalogBuildMode, Box<dyn std::error::Error>> {
|
||||||
let base_config = fs::read_to_string("tauri.conf.json")?;
|
let base_config = fs::read_to_string("tauri.conf.json")?;
|
||||||
let config_override = env::var_os("TAURI_CONFIG")
|
let config_override = env::var_os("TAURI_CONFIG")
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ use std::{
|
|||||||
|
|
||||||
use eyre::bail;
|
use eyre::bail;
|
||||||
use lanspread_compat::catalog_bundle::{LoadedCatalog, load_catalog_bundle};
|
use lanspread_compat::catalog_bundle::{LoadedCatalog, load_catalog_bundle};
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use lanspread_db::content_manifest::CatalogContentManifest;
|
||||||
use lanspread_db::{
|
use lanspread_db::{
|
||||||
content_manifest::CatalogBundle,
|
content_manifest::CatalogBundle,
|
||||||
db::{Availability, Game, GameDB},
|
db::{Availability, Game, GameDB},
|
||||||
@@ -73,6 +75,13 @@ use tracing_subscriber::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
mod sharing_policy;
|
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/
|
// 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]
|
#[tauri::command]
|
||||||
async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<usize> {
|
async fn get_peer_count(state: tauri::State<'_, LanSpreadState>) -> tauri::Result<usize> {
|
||||||
let _app_invoke = enter_app_invoke(state.inner())?;
|
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}"))
|
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"))]
|
#[cfg(any(test, target_os = "windows"))]
|
||||||
fn setup_process_exit_succeeded(exit_code: u32) -> bool {
|
fn setup_process_exit_succeeded(exit_code: u32) -> bool {
|
||||||
exit_code == 0
|
exit_code == 0
|
||||||
@@ -1695,9 +1648,9 @@ where
|
|||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn run_as_admin_and_wait(
|
fn run_as_admin_and_wait(
|
||||||
file: &str,
|
file: &std::ffi::OsStr,
|
||||||
params: &str,
|
params: &std::ffi::OsStr,
|
||||||
dir: &str,
|
dir: &std::ffi::OsStr,
|
||||||
show_cmd: windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD,
|
show_cmd: windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
use std::{ffi::OsStr, os::windows::ffi::OsStrExt};
|
use std::{ffi::OsStr, os::windows::ffi::OsStrExt};
|
||||||
@@ -1812,18 +1765,9 @@ fn run_as_admin_and_wait(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let file_wide = OsStr::new(file)
|
let file_wide = file.encode_wide().chain(Some(0)).collect::<Vec<_>>();
|
||||||
.encode_wide()
|
let params_wide = params.encode_wide().chain(Some(0)).collect::<Vec<_>>();
|
||||||
.chain(Some(0))
|
let dir_wide = dir.encode_wide().chain(Some(0)).collect::<Vec<_>>();
|
||||||
.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 runas_wide = OsStr::new("runas")
|
let runas_wide = OsStr::new("runas")
|
||||||
.encode_wide()
|
.encode_wide()
|
||||||
.chain(Some(0))
|
.chain(Some(0))
|
||||||
@@ -1876,6 +1820,38 @@ fn run_as_admin_and_wait(
|
|||||||
Ok(())
|
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")]
|
#[cfg(target_os = "windows")]
|
||||||
async fn run_game_windows(
|
async fn run_game_windows(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -1888,27 +1864,74 @@ async fn run_game_windows(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _serial_game_directory = state.inner().app_invokes.serialize_peer_startup().await;
|
||||||
let settings = launch_settings(&language, &username);
|
let settings = launch_settings(&language, &username);
|
||||||
let games_folder_lock = state.inner().games_folder.clone();
|
let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone());
|
||||||
let games_folder = {
|
if state
|
||||||
let guard = games_folder_lock.read().await;
|
.inner()
|
||||||
guard.clone()
|
.active_operations
|
||||||
};
|
.read()
|
||||||
|
.await
|
||||||
let games_folder = PathBuf::from(games_folder);
|
.contains_key(&id)
|
||||||
|
{
|
||||||
|
log::warn!("Ignoring run request while a game operation is active: {id}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if !games_folder.exists() {
|
if !games_folder.exists() {
|
||||||
log::error!("games_folder {} does not exist", games_folder.display());
|
log::error!("games_folder {} does not exist", games_folder.display());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let game_path = games_folder.join(id.clone());
|
let game_path = games_folder.join(id.clone());
|
||||||
|
let installed = state
|
||||||
let game_setup_bin = game_path.join(GAME_SETUP_SCRIPT);
|
.inner()
|
||||||
let game_start_bin = game_path.join(GAME_START_SCRIPT);
|
.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 {
|
let Some(state_dir) = state.inner().state_dir.get().cloned() else {
|
||||||
log::error!("app state directory is not initialized; cannot run game");
|
log::error!("app state directory is not initialized; cannot run game");
|
||||||
return Ok(());
|
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) {
|
let setup_done_file = match lanspread_peer::setup_done_path(&state_dir, &id) {
|
||||||
Ok(path) => path,
|
Ok(path) => path,
|
||||||
@@ -1917,22 +1940,28 @@ async fn run_game_windows(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !setup_done_file.exists() && game_setup_bin.exists() {
|
if !setup_done_file.exists()
|
||||||
if !local_install_is_present(&game_path) {
|
&& let Some(authority) = setup_authority
|
||||||
log::warn!(
|
{
|
||||||
"local install is missing for {}; skipping game_setup",
|
let setup_params = match windows_launch::worker_parameters(
|
||||||
game_path.display()
|
windows_launch::ElevatedScriptRole::Setup,
|
||||||
);
|
&games_folder,
|
||||||
return Ok(());
|
&id,
|
||||||
}
|
&settings.language,
|
||||||
|
&settings.username,
|
||||||
let setup_params = script_params(&game_setup_bin, &id, &settings);
|
authority,
|
||||||
let game_dir = game_path.display().to_string();
|
) {
|
||||||
|
Ok(parameters) => parameters,
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("failed to prepare {GAME_SETUP_SCRIPT}: {error}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
if let Err(err) = scoped_blocking(|| {
|
if let Err(err) = scoped_blocking(|| {
|
||||||
run_as_admin_and_wait(
|
run_as_admin_and_wait(
|
||||||
"cmd.exe",
|
worker.as_os_str(),
|
||||||
&setup_params,
|
std::ffi::OsStr::new(&setup_params),
|
||||||
&game_dir,
|
worker_directory.as_os_str(),
|
||||||
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
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);
|
apply_launch_settings(&state_dir, &game_path, &id, &language, &username);
|
||||||
|
|
||||||
if game_start_bin.exists() {
|
if let Some(authority) = game_authority {
|
||||||
// Game processes are intentionally user-owned: unlike setup, their
|
let game_params = match windows_launch::worker_parameters(
|
||||||
// lifetime is not an install transaction or a launcher state boundary.
|
windows_launch::ElevatedScriptRole::Game,
|
||||||
let result = run_as_admin_detached(
|
&games_folder,
|
||||||
"cmd.exe",
|
&id,
|
||||||
&script_params(&game_start_bin, &id, &settings),
|
&settings.language,
|
||||||
&game_path.display().to_string(),
|
&settings.username,
|
||||||
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
authority,
|
||||||
);
|
) {
|
||||||
|
Ok(parameters) => parameters,
|
||||||
if !result {
|
Err(error) => {
|
||||||
log::error!("failed to run {GAME_START_SCRIPT}");
|
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);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _serial_game_directory = state.inner().app_invokes.serialize_peer_startup().await;
|
||||||
let settings = launch_settings(&language, &username);
|
let settings = launch_settings(&language, &username);
|
||||||
let games_folder = PathBuf::from(state.inner().games_folder.read().await.clone());
|
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() {
|
if !games_folder.exists() {
|
||||||
log::error!("games_folder {} does not exist", games_folder.display());
|
log::error!("games_folder {} does not exist", games_folder.display());
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
let game_path = games_folder.join(id.clone());
|
let game_path = games_folder.join(id.clone());
|
||||||
if !local_install_is_present(&game_path) {
|
let installed = state
|
||||||
log::warn!(
|
.inner()
|
||||||
"local install is missing for {}; skipping {SERVER_START_SCRIPT}",
|
.games
|
||||||
game_path.display()
|
.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);
|
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 !game_path.is_dir() {
|
||||||
if !server_start_bin.is_file() {
|
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"server start script is missing for {}: {}",
|
"Game directory disappeared before server launch: {}",
|
||||||
id,
|
game_path.display()
|
||||||
server_start_bin.display()
|
|
||||||
);
|
);
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -2067,19 +2166,21 @@ async fn start_server_windows(
|
|||||||
};
|
};
|
||||||
apply_launch_settings(&state_dir, &game_path, &id, &language, &username);
|
apply_launch_settings(&state_dir, &game_path, &id, &language, &username);
|
||||||
|
|
||||||
// Hosted servers are intentionally user-owned and may outlive the launcher.
|
// The worker transfers its verification locks to the hosted command shell,
|
||||||
let result = run_as_admin_detached(
|
// which remains detached from, and may outlive, the launcher.
|
||||||
"cmd.exe",
|
let result = scoped_blocking(|| {
|
||||||
&server_script_params(&server_start_bin, &id, &settings),
|
run_as_admin_and_wait(
|
||||||
&game_path.display().to_string(),
|
worker.as_os_str(),
|
||||||
windows::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL,
|
std::ffi::OsStr::new(&server_params),
|
||||||
);
|
worker_directory.as_os_str(),
|
||||||
|
windows::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
||||||
if !result {
|
)
|
||||||
log::error!("failed to run {SERVER_START_SCRIPT}");
|
});
|
||||||
|
if let Err(error) = &result {
|
||||||
|
log::error!("failed to start {SERVER_START_SCRIPT}: {error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(result)
|
Ok(result.is_ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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) {
|
fn clear_local_game_state(game: &mut Game) {
|
||||||
game.set_downloaded(false);
|
game.set_downloaded(false);
|
||||||
game.installed = 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]
|
#[test]
|
||||||
fn server_host_capability_requires_installed_game_with_script() {
|
fn server_host_capability_requires_installed_game_with_script() {
|
||||||
let root = std::env::temp_dir().join(format!(
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
|||||||
@@ -7,5 +7,10 @@ use mimalloc::MiMalloc;
|
|||||||
static GLOBAL: MiMalloc = MiMalloc;
|
static GLOBAL: MiMalloc = MiMalloc;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
if let Some(exit_code) = lanspread_tauri_deno_ts_lib::elevated_script_worker_exit_code() {
|
||||||
|
std::process::exit(exit_code);
|
||||||
|
}
|
||||||
|
|
||||||
lanspread_tauri_deno_ts_lib::run();
|
lanspread_tauri_deno_ts_lib::run();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,1221 @@
|
|||||||
|
use std::{
|
||||||
|
ffi::OsString,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
str::FromStr as _,
|
||||||
|
};
|
||||||
|
|
||||||
|
use lanspread_db::content_manifest::{
|
||||||
|
Blake3Digest,
|
||||||
|
CatalogContentIndex,
|
||||||
|
CatalogContentManifest,
|
||||||
|
CatalogEntryKind,
|
||||||
|
ContentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) const ELEVATED_WORKER_SWITCH: &str = "--lanspread-elevated-script-worker-v1";
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const EMBEDDED_CATALOG_INDEX: &[u8] = include_bytes!(concat!(
|
||||||
|
env!("OUT_DIR"),
|
||||||
|
"/embedded-catalog-content-index-v1.jsonl"
|
||||||
|
));
|
||||||
|
const CMD_ARGUMENTS: &str = concat!(
|
||||||
|
"\"%__LANSPREAD_VERIFIED_SCRIPT%\" ",
|
||||||
|
"\"%__LANSPREAD_SCRIPT_SOURCE%\" ",
|
||||||
|
"\"%__LANSPREAD_SCRIPT_GAME_ID%\" ",
|
||||||
|
"\"%__LANSPREAD_SCRIPT_LANGUAGE%\" ",
|
||||||
|
"\"%__LANSPREAD_SCRIPT_USERNAME%\""
|
||||||
|
);
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const INSTALL_OWNED_MARKER: &str = ".lanspread_owned";
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const INSTALLED_GAME_DIRECTORY: &str = "local";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) enum ElevatedScriptRole {
|
||||||
|
Setup,
|
||||||
|
Game,
|
||||||
|
Server,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ElevatedScriptRole {
|
||||||
|
pub(crate) const fn script_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Setup => crate::GAME_SETUP_SCRIPT,
|
||||||
|
Self::Game => crate::GAME_START_SCRIPT,
|
||||||
|
Self::Server => crate::SERVER_START_SCRIPT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn worker_argument(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Setup => "setup",
|
||||||
|
Self::Game => "game",
|
||||||
|
Self::Server => "server",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn cmd_mode(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Setup | Self::Game => "/c",
|
||||||
|
Self::Server => "/k",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn show_window(self) -> u16 {
|
||||||
|
match self {
|
||||||
|
Self::Setup | Self::Game => 0,
|
||||||
|
Self::Server => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn waits_for_script(self) -> bool {
|
||||||
|
matches!(self, Self::Setup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&str> for ElevatedScriptRole {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||||
|
match value {
|
||||||
|
"setup" => Ok(Self::Setup),
|
||||||
|
"game" => Ok(Self::Game),
|
||||||
|
"server" => Ok(Self::Server),
|
||||||
|
_ => Err(format!("unknown elevated script role: {value:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) struct CatalogScriptAuthority {
|
||||||
|
content_id: ContentId,
|
||||||
|
size: u64,
|
||||||
|
blake3: Blake3Digest,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves one fixed launcher script exclusively from the trusted catalog.
|
||||||
|
///
|
||||||
|
/// A missing entry means that the game does not provide that optional action.
|
||||||
|
/// An entry with the expected name but a non-file shape is an invalid catalog,
|
||||||
|
/// not an absent script.
|
||||||
|
pub(crate) fn catalog_script_authority(
|
||||||
|
manifest: &CatalogContentManifest,
|
||||||
|
role: ElevatedScriptRole,
|
||||||
|
) -> Result<Option<CatalogScriptAuthority>, String> {
|
||||||
|
let Some(entry) = manifest.file_entry(role.script_name()) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if entry.kind() != CatalogEntryKind::File {
|
||||||
|
return Err(format!(
|
||||||
|
"catalog entry {} for {} is not a regular file",
|
||||||
|
role.script_name(),
|
||||||
|
manifest.game_id()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let blake3 = entry.file_blake3().ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"catalog entry {} for {} has no whole-file digest",
|
||||||
|
role.script_name(),
|
||||||
|
manifest.game_id()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Some(CatalogScriptAuthority {
|
||||||
|
content_id: manifest.content_id(),
|
||||||
|
size: entry.size(),
|
||||||
|
blake3,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_unchanged_authority(
|
||||||
|
role: ElevatedScriptRole,
|
||||||
|
claimed: CatalogScriptAuthority,
|
||||||
|
reloaded: CatalogScriptAuthority,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if claimed == reloaded {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"catalog authority changed before launching {}",
|
||||||
|
role.script_name()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_embedded_catalog_authority(
|
||||||
|
index: &CatalogContentIndex,
|
||||||
|
game_id: &str,
|
||||||
|
claimed: CatalogScriptAuthority,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let identity = index
|
||||||
|
.content_identity(game_id)
|
||||||
|
.ok_or_else(|| format!("embedded catalog does not contain game {game_id}"))?;
|
||||||
|
if identity.content_id == claimed.content_id {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"embedded catalog content identity does not match the launch request for {game_id}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||||
|
struct WorkerRequest {
|
||||||
|
role: ElevatedScriptRole,
|
||||||
|
games_folder: PathBuf,
|
||||||
|
game_id: String,
|
||||||
|
language: String,
|
||||||
|
username: String,
|
||||||
|
claimed_authority: CatalogScriptAuthority,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn worker_parameters(
|
||||||
|
role: ElevatedScriptRole,
|
||||||
|
games_folder: &Path,
|
||||||
|
game_id: &str,
|
||||||
|
language: &str,
|
||||||
|
username: &str,
|
||||||
|
authority: CatalogScriptAuthority,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let games_folder = games_folder.to_str().ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"games directory cannot be represented as UTF-8: {}",
|
||||||
|
games_folder.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let arguments = [
|
||||||
|
ELEVATED_WORKER_SWITCH.to_owned(),
|
||||||
|
role.worker_argument().to_owned(),
|
||||||
|
games_folder.to_owned(),
|
||||||
|
game_id.to_owned(),
|
||||||
|
language.to_owned(),
|
||||||
|
username.to_owned(),
|
||||||
|
authority.content_id.to_string(),
|
||||||
|
authority.size.to_string(),
|
||||||
|
authority.blake3.to_string(),
|
||||||
|
];
|
||||||
|
Ok(arguments
|
||||||
|
.iter()
|
||||||
|
.map(|argument| quote_windows_argument(argument))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quotes one argument according to the Windows `CommandLineToArgvW` rules.
|
||||||
|
///
|
||||||
|
/// `ShellExecuteExW` accepts one parameter string rather than an argv array, so
|
||||||
|
/// backslashes before a quote or the closing delimiter must be doubled.
|
||||||
|
fn quote_windows_argument(value: &str) -> String {
|
||||||
|
let mut quoted = String::from("\"");
|
||||||
|
let mut backslashes = 0_usize;
|
||||||
|
for character in value.chars() {
|
||||||
|
if character == '\\' {
|
||||||
|
backslashes += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if character == '"' {
|
||||||
|
quoted.extend(std::iter::repeat_n('\\', backslashes * 2 + 1));
|
||||||
|
quoted.push('"');
|
||||||
|
} else {
|
||||||
|
quoted.extend(std::iter::repeat_n('\\', backslashes));
|
||||||
|
quoted.push(character);
|
||||||
|
}
|
||||||
|
backslashes = 0;
|
||||||
|
}
|
||||||
|
quoted.extend(std::iter::repeat_n('\\', backslashes * 2));
|
||||||
|
quoted.push('"');
|
||||||
|
quoted
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_worker_arguments(
|
||||||
|
arguments: impl IntoIterator<Item = OsString>,
|
||||||
|
) -> Result<Option<WorkerRequest>, String> {
|
||||||
|
let mut arguments = arguments.into_iter();
|
||||||
|
let Some(first) = arguments.next() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if first != ELEVATED_WORKER_SWITCH {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let role = next_utf8(&mut arguments, "role")?;
|
||||||
|
let games_folder = PathBuf::from(next_utf8(&mut arguments, "games directory")?);
|
||||||
|
let game_id = next_utf8(&mut arguments, "game ID")?;
|
||||||
|
let language = next_utf8(&mut arguments, "language")?;
|
||||||
|
let username = next_utf8(&mut arguments, "username")?;
|
||||||
|
let content_id = ContentId::from_str(&next_utf8(&mut arguments, "content ID")?)
|
||||||
|
.map_err(|error| format!("invalid worker content ID: {error}"))?;
|
||||||
|
let size = u64::from_str(&next_utf8(&mut arguments, "script size")?)
|
||||||
|
.map_err(|error| format!("invalid worker script size: {error}"))?;
|
||||||
|
let blake3 = Blake3Digest::from_str(&next_utf8(&mut arguments, "script digest")?)
|
||||||
|
.map_err(|error| format!("invalid worker script digest: {error}"))?;
|
||||||
|
if arguments.next().is_some() {
|
||||||
|
return Err("elevated script worker received trailing arguments".to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(WorkerRequest {
|
||||||
|
role: ElevatedScriptRole::try_from(role.as_str())?,
|
||||||
|
games_folder,
|
||||||
|
game_id,
|
||||||
|
language,
|
||||||
|
username,
|
||||||
|
claimed_authority: CatalogScriptAuthority {
|
||||||
|
content_id,
|
||||||
|
size,
|
||||||
|
blake3,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_utf8(
|
||||||
|
arguments: &mut impl Iterator<Item = OsString>,
|
||||||
|
label: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
arguments
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| format!("elevated script worker is missing {label}"))?
|
||||||
|
.into_string()
|
||||||
|
.map_err(|_| format!("elevated script worker {label} is not valid UTF-8"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_worker_settings(language: &str, username: &str) -> Result<(), String> {
|
||||||
|
let sanitized = crate::launch_settings(language, username);
|
||||||
|
if sanitized.language == language && sanitized.username == username {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("elevated script worker received unsanitized launch settings".to_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub(crate) fn elevated_worker_exit_code() -> Option<i32> {
|
||||||
|
match parse_worker_arguments(std::env::args_os().skip(1)) {
|
||||||
|
Ok(None) => None,
|
||||||
|
Ok(Some(request)) => Some(match run_worker(request) {
|
||||||
|
Ok(()) => 0,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("elevated script worker failed: {error}");
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("elevated script worker rejected its request: {error}");
|
||||||
|
Some(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn run_worker(request: WorkerRequest) -> Result<(), String> {
|
||||||
|
use lanspread_compat::catalog_bundle::load_catalog_bundle;
|
||||||
|
|
||||||
|
require_elevated_token()?;
|
||||||
|
validate_worker_settings(&request.language, &request.username)?;
|
||||||
|
let embedded_index = CatalogContentIndex::from_json_slice(EMBEDDED_CATALOG_INDEX)
|
||||||
|
.map_err(|error| format!("embedded catalog launch authority is invalid: {error:#}"))?;
|
||||||
|
require_embedded_catalog_authority(
|
||||||
|
&embedded_index,
|
||||||
|
&request.game_id,
|
||||||
|
request.claimed_authority,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let executable = std::env::current_exe()
|
||||||
|
.map_err(|error| format!("failed to resolve elevated worker executable: {error}"))?;
|
||||||
|
let resources = executable.parent().ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"elevated worker executable has no resource directory: {}",
|
||||||
|
executable.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.map_err(|error| format!("failed to create elevated catalog runtime: {error}"))?;
|
||||||
|
let loaded = runtime
|
||||||
|
.block_on(load_catalog_bundle(
|
||||||
|
&resources.join("game.db"),
|
||||||
|
&resources.join("manifests"),
|
||||||
|
))
|
||||||
|
.map_err(|error| format!("failed to reload bundled catalog authority: {error:#}"))?;
|
||||||
|
let manifest = loaded
|
||||||
|
.bundle()
|
||||||
|
.manifest(&request.game_id)
|
||||||
|
.map_err(|error| format!("failed to load catalog manifest: {error:#}"))?;
|
||||||
|
let authority = catalog_script_authority(&manifest, request.role)?
|
||||||
|
.ok_or_else(|| format!("catalog does not authorize {}", request.role.script_name()))?;
|
||||||
|
require_unchanged_authority(request.role, request.claimed_authority, authority)?;
|
||||||
|
|
||||||
|
let locked = LockedCatalogScript::open_and_verify(
|
||||||
|
&request.games_folder,
|
||||||
|
&request.game_id,
|
||||||
|
request.role,
|
||||||
|
authority,
|
||||||
|
)?;
|
||||||
|
locked.run(&request)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn require_elevated_token() -> Result<(), String> {
|
||||||
|
use windows::Win32::{
|
||||||
|
Foundation::{CloseHandle, HANDLE},
|
||||||
|
Security::{GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation},
|
||||||
|
System::Threading::{GetCurrentProcess, OpenProcessToken},
|
||||||
|
};
|
||||||
|
|
||||||
|
struct OwnedToken(HANDLE);
|
||||||
|
|
||||||
|
impl Drop for OwnedToken {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Err(error) = unsafe { CloseHandle(self.0) } {
|
||||||
|
eprintln!("failed to close elevated worker token handle: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut raw_token = HANDLE::default();
|
||||||
|
unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut raw_token) }
|
||||||
|
.map_err(|error| format!("failed to inspect elevated worker token: {error}"))?;
|
||||||
|
let token = OwnedToken(raw_token);
|
||||||
|
let mut elevation = TOKEN_ELEVATION::default();
|
||||||
|
let mut returned = 0_u32;
|
||||||
|
let elevation_size = u32::try_from(std::mem::size_of::<TOKEN_ELEVATION>())
|
||||||
|
.map_err(|error| format!("invalid token information size: {error}"))?;
|
||||||
|
unsafe {
|
||||||
|
GetTokenInformation(
|
||||||
|
token.0,
|
||||||
|
TokenElevation,
|
||||||
|
Some((&raw mut elevation).cast()),
|
||||||
|
elevation_size,
|
||||||
|
&raw mut returned,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.map_err(|error| format!("failed to query elevated worker token: {error}"))?;
|
||||||
|
if returned != elevation_size || elevation.TokenIsElevated == 0 {
|
||||||
|
return Err("elevated script worker does not have an elevated token".to_owned());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
struct LockedCatalogScript {
|
||||||
|
// Each directory is opened without delete sharing. Keeping the complete
|
||||||
|
// absolute chain prevents an ancestor rename from changing pathname
|
||||||
|
// resolution while cmd.exe opens and interprets the script.
|
||||||
|
directories: Vec<std::fs::File>,
|
||||||
|
// Current installs carry this marker inside local/. Keeping both objects
|
||||||
|
// open makes the installed-state check stable through script execution.
|
||||||
|
installed_directory: std::fs::File,
|
||||||
|
ownership_marker: std::fs::File,
|
||||||
|
// Opened with FILE_SHARE_READ only, excluding both data writes and path
|
||||||
|
// replacement until the command interpreter exits.
|
||||||
|
script: std::fs::File,
|
||||||
|
game_directory: PathBuf,
|
||||||
|
script_path: PathBuf,
|
||||||
|
role: ElevatedScriptRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
impl LockedCatalogScript {
|
||||||
|
fn open_and_verify(
|
||||||
|
games_folder: &Path,
|
||||||
|
game_id: &str,
|
||||||
|
role: ElevatedScriptRole,
|
||||||
|
authority: CatalogScriptAuthority,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
if !games_folder.is_absolute() {
|
||||||
|
return Err(format!(
|
||||||
|
"games directory is not absolute: {}",
|
||||||
|
games_folder.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let game_directory = games_folder.join(game_id);
|
||||||
|
let directories = lock_absolute_directory_chain(&game_directory)?;
|
||||||
|
let installed_path = game_directory.join(INSTALLED_GAME_DIRECTORY);
|
||||||
|
let installed_directory = open_locked_directory(&installed_path)?;
|
||||||
|
let ownership_marker = open_locked_file(
|
||||||
|
&installed_path.join(INSTALL_OWNED_MARKER),
|
||||||
|
windows::Win32::Storage::FileSystem::FILE_SHARE_READ.0,
|
||||||
|
)?;
|
||||||
|
if ownership_marker
|
||||||
|
.metadata()
|
||||||
|
.map_err(|error| format!("failed to inspect install ownership marker: {error}"))?
|
||||||
|
.len()
|
||||||
|
!= 0
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"install ownership marker is not empty: {}",
|
||||||
|
installed_path.join(INSTALL_OWNED_MARKER).display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let script_path = game_directory.join(role.script_name());
|
||||||
|
let mut script = open_locked_file(
|
||||||
|
&script_path,
|
||||||
|
windows::Win32::Storage::FileSystem::FILE_SHARE_READ.0,
|
||||||
|
)?;
|
||||||
|
verify_script_digest(&mut script, &script_path, authority)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
directories,
|
||||||
|
installed_directory,
|
||||||
|
ownership_marker,
|
||||||
|
script,
|
||||||
|
game_directory,
|
||||||
|
script_path,
|
||||||
|
role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&self, request: &WorkerRequest) -> Result<(), String> {
|
||||||
|
use std::os::windows::process::CommandExt as _;
|
||||||
|
|
||||||
|
const SCRIPT_ENV: &str = "__LANSPREAD_VERIFIED_SCRIPT";
|
||||||
|
const SOURCE_ENV: &str = "__LANSPREAD_SCRIPT_SOURCE";
|
||||||
|
const GAME_ID_ENV: &str = "__LANSPREAD_SCRIPT_GAME_ID";
|
||||||
|
const LANGUAGE_ENV: &str = "__LANSPREAD_SCRIPT_LANGUAGE";
|
||||||
|
const USERNAME_ENV: &str = "__LANSPREAD_SCRIPT_USERNAME";
|
||||||
|
let command_processor = system_command_processor()?;
|
||||||
|
let mut command = std::process::Command::new(&command_processor);
|
||||||
|
command
|
||||||
|
.arg("/d")
|
||||||
|
.arg("/v:off")
|
||||||
|
.arg("/s")
|
||||||
|
.arg(self.role.cmd_mode())
|
||||||
|
// cmd.exe requires another pair of quotes around the complete
|
||||||
|
// command when the executable path itself is quoted.
|
||||||
|
.raw_arg(format!("\"{CMD_ARGUMENTS}\""))
|
||||||
|
.env(SCRIPT_ENV, &self.script_path)
|
||||||
|
.env(SOURCE_ENV, "local")
|
||||||
|
.env(GAME_ID_ENV, &request.game_id)
|
||||||
|
.env(LANGUAGE_ENV, &request.language)
|
||||||
|
.env(USERNAME_ENV, &request.username)
|
||||||
|
.current_dir(&self.game_directory)
|
||||||
|
.show_window(self.role.show_window());
|
||||||
|
|
||||||
|
let mut child = command.spawn().map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"failed to start trusted command processor {}: {error}",
|
||||||
|
command_processor.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if let Err(error) = self.duplicate_locks_into(&child) {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(status)) => return require_successful_exit(status),
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(observe_error) => {
|
||||||
|
return Err(settle_child_after_wait_error(
|
||||||
|
&mut child,
|
||||||
|
format!(
|
||||||
|
"failed to transfer verified launch handles ({error}); child-state \
|
||||||
|
observation also failed: {observe_error}"
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(settle_child_after_wait_error(
|
||||||
|
&mut child,
|
||||||
|
format!("failed to transfer verified launch handles: {error}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !self.role.waits_for_script() {
|
||||||
|
// The detached command interpreter now owns duplicate, non-inheritable
|
||||||
|
// references to every path lock. Dropping this worker cannot release
|
||||||
|
// them before cmd.exe exits.
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let status = match child.wait() {
|
||||||
|
Ok(status) => status,
|
||||||
|
Err(error) => {
|
||||||
|
return Err(settle_child_after_wait_error(
|
||||||
|
&mut child,
|
||||||
|
format!("failed to wait for elevated script: {error}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
require_successful_exit(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duplicate_locks_into(&self, child: &std::process::Child) -> Result<(), String> {
|
||||||
|
use std::os::windows::io::AsRawHandle as _;
|
||||||
|
|
||||||
|
use windows::Win32::{
|
||||||
|
Foundation::{DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE},
|
||||||
|
System::Threading::GetCurrentProcess,
|
||||||
|
};
|
||||||
|
|
||||||
|
let source_process = unsafe { GetCurrentProcess() };
|
||||||
|
let target_process = HANDLE(child.as_raw_handle());
|
||||||
|
let handles = self.directories.iter().chain([
|
||||||
|
&self.installed_directory,
|
||||||
|
&self.ownership_marker,
|
||||||
|
&self.script,
|
||||||
|
]);
|
||||||
|
for handle in handles {
|
||||||
|
let mut target_handle = HANDLE::default();
|
||||||
|
unsafe {
|
||||||
|
DuplicateHandle(
|
||||||
|
source_process,
|
||||||
|
HANDLE(handle.as_raw_handle()),
|
||||||
|
target_process,
|
||||||
|
&raw mut target_handle,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
DUPLICATE_SAME_ACCESS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.map_err(|error| format!("DuplicateHandle failed: {error}"))?;
|
||||||
|
// `target_handle` names the new handle in the child process. It must
|
||||||
|
// not be closed in this process; the child owns it until exit.
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn require_successful_exit(status: std::process::ExitStatus) -> Result<(), String> {
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(match status.code() {
|
||||||
|
Some(code) => format!("elevated script exited with status {code}"),
|
||||||
|
None => "elevated script terminated without an exit status".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn settle_child_after_wait_error(child: &mut std::process::Child, initial_error: String) -> String {
|
||||||
|
let mut attempts = 0_u64;
|
||||||
|
let mut first_kill_error = None;
|
||||||
|
let mut first_wait_error = None;
|
||||||
|
let exit_status = loop {
|
||||||
|
attempts = attempts.saturating_add(1);
|
||||||
|
if let Err(error) = child.kill()
|
||||||
|
&& first_kill_error.is_none()
|
||||||
|
{
|
||||||
|
first_kill_error = Some(error.to_string());
|
||||||
|
}
|
||||||
|
match child.wait() {
|
||||||
|
Ok(status) => break status,
|
||||||
|
Err(error) => {
|
||||||
|
if first_wait_error.is_none() {
|
||||||
|
first_wait_error = Some(error.to_string());
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let kill = first_kill_error.map_or_else(
|
||||||
|
|| "termination attempts succeeded".to_owned(),
|
||||||
|
|error| format!("a termination attempt failed ({error})"),
|
||||||
|
);
|
||||||
|
let wait = first_wait_error.map_or_else(
|
||||||
|
|| "settlement waits succeeded".to_owned(),
|
||||||
|
|error| format!("a settlement wait failed ({error})"),
|
||||||
|
);
|
||||||
|
format!(
|
||||||
|
"{initial_error}; process settlement required {attempts} attempt(s); {kill}; {wait}; \
|
||||||
|
elevated script settled with {exit_status}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn lock_absolute_directory_chain(path: &Path) -> Result<Vec<std::fs::File>, String> {
|
||||||
|
use std::path::{Component, Prefix};
|
||||||
|
|
||||||
|
let mut current = PathBuf::new();
|
||||||
|
let mut prefixes = Vec::new();
|
||||||
|
let mut rooted = false;
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
Component::Prefix(prefix) => {
|
||||||
|
if !current.as_os_str().is_empty() {
|
||||||
|
return Err(format!("invalid Windows path prefix: {}", path.display()));
|
||||||
|
}
|
||||||
|
match prefix.kind() {
|
||||||
|
Prefix::Disk(_)
|
||||||
|
| Prefix::UNC(_, _)
|
||||||
|
| Prefix::VerbatimDisk(_)
|
||||||
|
| Prefix::VerbatimUNC(_, _) => {}
|
||||||
|
_ => {
|
||||||
|
return Err(format!(
|
||||||
|
"unsupported Windows path namespace: {}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.push(prefix.as_os_str());
|
||||||
|
}
|
||||||
|
Component::RootDir => {
|
||||||
|
current.push(component.as_os_str());
|
||||||
|
rooted = true;
|
||||||
|
prefixes.push(current.clone());
|
||||||
|
}
|
||||||
|
Component::Normal(component) if rooted => {
|
||||||
|
current.push(component);
|
||||||
|
prefixes.push(current.clone());
|
||||||
|
}
|
||||||
|
Component::CurDir | Component::ParentDir | Component::Normal(_) => {
|
||||||
|
return Err(format!(
|
||||||
|
"games directory is not a canonical absolute Windows path: {}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if prefixes.is_empty() {
|
||||||
|
return Err(format!("empty Windows directory path: {}", path.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixes
|
||||||
|
.iter()
|
||||||
|
.map(|prefix| open_locked_directory(prefix))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn open_locked_directory(path: &Path) -> Result<std::fs::File, String> {
|
||||||
|
use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _};
|
||||||
|
|
||||||
|
use windows::Win32::Storage::FileSystem::{
|
||||||
|
FILE_ATTRIBUTE_REPARSE_POINT,
|
||||||
|
FILE_FLAG_BACKUP_SEMANTICS,
|
||||||
|
FILE_FLAG_OPEN_REPARSE_POINT,
|
||||||
|
FILE_SHARE_READ,
|
||||||
|
FILE_SHARE_WRITE,
|
||||||
|
};
|
||||||
|
|
||||||
|
let directory = std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.share_mode(FILE_SHARE_READ.0 | FILE_SHARE_WRITE.0)
|
||||||
|
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0 | FILE_FLAG_OPEN_REPARSE_POINT.0)
|
||||||
|
.open(path)
|
||||||
|
.map_err(|error| format!("failed to lock directory {}: {error}", path.display()))?;
|
||||||
|
let metadata = directory
|
||||||
|
.metadata()
|
||||||
|
.map_err(|error| format!("failed to inspect directory {}: {error}", path.display()))?;
|
||||||
|
if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"launch directory is not a regular non-reparse directory: {}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn open_locked_file(path: &Path, share_mode: u32) -> Result<std::fs::File, String> {
|
||||||
|
use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _};
|
||||||
|
|
||||||
|
use windows::Win32::Storage::FileSystem::{
|
||||||
|
FILE_ATTRIBUTE_REPARSE_POINT,
|
||||||
|
FILE_FLAG_OPEN_REPARSE_POINT,
|
||||||
|
FILE_FLAG_SEQUENTIAL_SCAN,
|
||||||
|
};
|
||||||
|
|
||||||
|
let file = std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.share_mode(share_mode)
|
||||||
|
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0 | FILE_FLAG_SEQUENTIAL_SCAN.0)
|
||||||
|
.open(path)
|
||||||
|
.map_err(|error| format!("failed to lock file {}: {error}", path.display()))?;
|
||||||
|
let metadata = file
|
||||||
|
.metadata()
|
||||||
|
.map_err(|error| format!("failed to inspect file {}: {error}", path.display()))?;
|
||||||
|
if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"launch file is not a regular non-reparse file: {}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_script_digest(
|
||||||
|
file: &mut std::fs::File,
|
||||||
|
path: &Path,
|
||||||
|
authority: CatalogScriptAuthority,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
use std::io::{Read as _, Seek as _, SeekFrom};
|
||||||
|
|
||||||
|
let actual_size = file
|
||||||
|
.metadata()
|
||||||
|
.map_err(|error| format!("failed to inspect script {}: {error}", path.display()))?
|
||||||
|
.len();
|
||||||
|
if actual_size != authority.size {
|
||||||
|
return Err(format!(
|
||||||
|
"catalog size mismatch for {}: expected {}, found {actual_size}",
|
||||||
|
path.display(),
|
||||||
|
authority.size
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut hasher = blake3::Hasher::new();
|
||||||
|
let mut observed = 0_u64;
|
||||||
|
let mut buffer = vec![0_u8; 64 * 1024];
|
||||||
|
loop {
|
||||||
|
let read = file
|
||||||
|
.read(&mut buffer)
|
||||||
|
.map_err(|error| format!("failed to hash script {}: {error}", path.display()))?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
observed = observed
|
||||||
|
.checked_add(u64::try_from(read).map_err(|error| error.to_string())?)
|
||||||
|
.ok_or_else(|| format!("script size overflow while hashing {}", path.display()))?;
|
||||||
|
if observed > authority.size {
|
||||||
|
return Err(format!(
|
||||||
|
"script grew beyond its catalog size while hashing {}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
hasher.update(&buffer[..read]);
|
||||||
|
}
|
||||||
|
if observed != authority.size {
|
||||||
|
return Err(format!(
|
||||||
|
"catalog size mismatch for {}: expected {}, read {observed}",
|
||||||
|
path.display(),
|
||||||
|
authority.size
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let actual = Blake3Digest::from_bytes(*hasher.finalize().as_bytes());
|
||||||
|
if actual != authority.blake3 {
|
||||||
|
return Err(format!(
|
||||||
|
"catalog BLAKE3 mismatch for {}: expected {}, found {actual}",
|
||||||
|
path.display(),
|
||||||
|
authority.blake3
|
||||||
|
));
|
||||||
|
}
|
||||||
|
file.seek(SeekFrom::Start(0))
|
||||||
|
.map_err(|error| format!("failed to rewind script {}: {error}", path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn system_command_processor() -> Result<PathBuf, String> {
|
||||||
|
use std::os::windows::ffi::OsStringExt as _;
|
||||||
|
|
||||||
|
use windows::Win32::System::SystemInformation::GetSystemDirectoryW;
|
||||||
|
|
||||||
|
let mut buffer = vec![0_u16; 260];
|
||||||
|
loop {
|
||||||
|
let length = unsafe { GetSystemDirectoryW(Some(&mut buffer)) };
|
||||||
|
if length == 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"GetSystemDirectoryW failed: {}",
|
||||||
|
windows::core::Error::from_win32()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let length = usize::try_from(length)
|
||||||
|
.map_err(|error| format!("invalid system directory length: {error}"))?;
|
||||||
|
if length < buffer.len() {
|
||||||
|
let mut path = PathBuf::from(OsString::from_wide(&buffer[..length]));
|
||||||
|
path.push("cmd.exe");
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
buffer.resize(length.saturating_add(1), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use lanspread_db::content_manifest::{
|
||||||
|
CatalogContentManifestBody,
|
||||||
|
CatalogExtractedEntry,
|
||||||
|
CatalogFileEntry,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn manifest_with_scripts() -> CatalogContentManifest {
|
||||||
|
let version = b"20260912";
|
||||||
|
let setup = b"setup";
|
||||||
|
let game = b"game";
|
||||||
|
let server = b"server";
|
||||||
|
CatalogContentManifest::seal(
|
||||||
|
CatalogContentManifestBody::new(
|
||||||
|
"game",
|
||||||
|
"20260912",
|
||||||
|
vec![
|
||||||
|
CatalogFileEntry::file(
|
||||||
|
"game_setup.cmd",
|
||||||
|
u64::try_from(setup.len()).expect("setup length should fit u64"),
|
||||||
|
Blake3Digest::hash(setup),
|
||||||
|
vec![Blake3Digest::hash(setup)],
|
||||||
|
)
|
||||||
|
.expect("setup entry should validate"),
|
||||||
|
CatalogFileEntry::file(
|
||||||
|
"game_start.cmd",
|
||||||
|
u64::try_from(game.len()).expect("game length should fit u64"),
|
||||||
|
Blake3Digest::hash(game),
|
||||||
|
vec![Blake3Digest::hash(game)],
|
||||||
|
)
|
||||||
|
.expect("game entry should validate"),
|
||||||
|
CatalogFileEntry::file(
|
||||||
|
"server_start.cmd",
|
||||||
|
u64::try_from(server.len()).expect("server length should fit u64"),
|
||||||
|
Blake3Digest::hash(server),
|
||||||
|
vec![Blake3Digest::hash(server)],
|
||||||
|
)
|
||||||
|
.expect("server entry should validate"),
|
||||||
|
CatalogFileEntry::file(
|
||||||
|
"version.ini",
|
||||||
|
u64::try_from(version.len()).expect("version length should fit u64"),
|
||||||
|
Blake3Digest::hash(version),
|
||||||
|
vec![Blake3Digest::hash(version)],
|
||||||
|
)
|
||||||
|
.expect("version entry should validate"),
|
||||||
|
],
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.expect("manifest body should validate"),
|
||||||
|
)
|
||||||
|
.expect("manifest should seal")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_roles_select_only_the_three_catalog_scripts() {
|
||||||
|
let manifest = manifest_with_scripts();
|
||||||
|
for (role, name, mode, show) in [
|
||||||
|
(ElevatedScriptRole::Setup, "game_setup.cmd", "/c", 0),
|
||||||
|
(ElevatedScriptRole::Game, "game_start.cmd", "/c", 0),
|
||||||
|
(ElevatedScriptRole::Server, "server_start.cmd", "/k", 1),
|
||||||
|
] {
|
||||||
|
assert_eq!(role.script_name(), name);
|
||||||
|
assert_eq!(role.cmd_mode(), mode);
|
||||||
|
assert_eq!(role.show_window(), show);
|
||||||
|
assert_eq!(role.waits_for_script(), role == ElevatedScriptRole::Setup);
|
||||||
|
assert!(
|
||||||
|
catalog_script_authority(&manifest, role)
|
||||||
|
.expect("catalog lookup should succeed")
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(ElevatedScriptRole::try_from("arbitrary").is_err());
|
||||||
|
assert_eq!(
|
||||||
|
CMD_ARGUMENTS,
|
||||||
|
concat!(
|
||||||
|
"\"%__LANSPREAD_VERIFIED_SCRIPT%\" ",
|
||||||
|
"\"%__LANSPREAD_SCRIPT_SOURCE%\" ",
|
||||||
|
"\"%__LANSPREAD_SCRIPT_GAME_ID%\" ",
|
||||||
|
"\"%__LANSPREAD_SCRIPT_LANGUAGE%\" ",
|
||||||
|
"\"%__LANSPREAD_SCRIPT_USERNAME%\""
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn streamed_only_script_never_authorizes_root_script_launch() {
|
||||||
|
let version = b"20260912";
|
||||||
|
let manifest = CatalogContentManifest::seal(
|
||||||
|
CatalogContentManifestBody::new(
|
||||||
|
"game",
|
||||||
|
"20260912",
|
||||||
|
vec![
|
||||||
|
CatalogFileEntry::file(
|
||||||
|
"version.ini",
|
||||||
|
u64::try_from(version.len()).expect("version length should fit u64"),
|
||||||
|
Blake3Digest::hash(version),
|
||||||
|
vec![Blake3Digest::hash(version)],
|
||||||
|
)
|
||||||
|
.expect("version entry should validate"),
|
||||||
|
],
|
||||||
|
vec![
|
||||||
|
CatalogExtractedEntry::file("game_start.cmd", 4, Blake3Digest::hash(b"game"))
|
||||||
|
.expect("streamed entry should validate"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.expect("manifest body should validate"),
|
||||||
|
)
|
||||||
|
.expect("manifest should seal");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
catalog_script_authority(&manifest, ElevatedScriptRole::Game)
|
||||||
|
.expect("catalog lookup should succeed"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_directory_cannot_impersonate_a_launch_script() {
|
||||||
|
let version = b"20260912";
|
||||||
|
let manifest = CatalogContentManifest::seal(
|
||||||
|
CatalogContentManifestBody::new(
|
||||||
|
"game",
|
||||||
|
"20260912",
|
||||||
|
vec![
|
||||||
|
CatalogFileEntry::directory("game_start.cmd")
|
||||||
|
.expect("directory entry should validate"),
|
||||||
|
CatalogFileEntry::file(
|
||||||
|
"version.ini",
|
||||||
|
u64::try_from(version.len()).expect("version length should fit u64"),
|
||||||
|
Blake3Digest::hash(version),
|
||||||
|
vec![Blake3Digest::hash(version)],
|
||||||
|
)
|
||||||
|
.expect("version entry should validate"),
|
||||||
|
],
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.expect("manifest body should validate"),
|
||||||
|
)
|
||||||
|
.expect("manifest should seal");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
catalog_script_authority(&manifest, ElevatedScriptRole::Game)
|
||||||
|
.expect_err("directory-shaped script authority must fail")
|
||||||
|
.contains("not a regular file")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_request_parser_rejects_open_ended_commands_and_trailing_data() {
|
||||||
|
let manifest = manifest_with_scripts();
|
||||||
|
let authority = catalog_script_authority(&manifest, ElevatedScriptRole::Game)
|
||||||
|
.expect("catalog lookup should succeed")
|
||||||
|
.expect("game script should exist");
|
||||||
|
let base = vec![
|
||||||
|
OsString::from(ELEVATED_WORKER_SWITCH),
|
||||||
|
OsString::from("game"),
|
||||||
|
OsString::from("C:\\Games"),
|
||||||
|
OsString::from("game"),
|
||||||
|
OsString::from("en"),
|
||||||
|
OsString::from("Alice"),
|
||||||
|
OsString::from(authority.content_id.to_string()),
|
||||||
|
OsString::from(authority.size.to_string()),
|
||||||
|
OsString::from(authority.blake3.to_string()),
|
||||||
|
];
|
||||||
|
let request = parse_worker_arguments(base.clone())
|
||||||
|
.expect("worker request should parse")
|
||||||
|
.expect("worker switch should dispatch");
|
||||||
|
assert_eq!(request.role, ElevatedScriptRole::Game);
|
||||||
|
assert_eq!(request.games_folder, Path::new("C:\\Games"));
|
||||||
|
|
||||||
|
let mut trailing = base;
|
||||||
|
trailing.push(OsString::from("cmd.exe /c attacker"));
|
||||||
|
assert!(parse_worker_arguments(trailing).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
parse_worker_arguments([OsString::from("--ordinary-app-argument")])
|
||||||
|
.expect("ordinary arguments should be ignored"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert!(validate_worker_settings("en", "Alice").is_ok());
|
||||||
|
assert!(validate_worker_settings("EN", "Alice").is_err());
|
||||||
|
assert!(validate_worker_settings("en", "Alice & calc.exe").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_rejects_every_catalog_claim_change() {
|
||||||
|
let manifest = manifest_with_scripts();
|
||||||
|
let authority = catalog_script_authority(&manifest, ElevatedScriptRole::Game)
|
||||||
|
.expect("catalog lookup should succeed")
|
||||||
|
.expect("game script should exist");
|
||||||
|
assert!(
|
||||||
|
require_unchanged_authority(ElevatedScriptRole::Game, authority, authority).is_ok()
|
||||||
|
);
|
||||||
|
for changed in [
|
||||||
|
CatalogScriptAuthority {
|
||||||
|
content_id: ContentId::from_bytes([1; 32]),
|
||||||
|
..authority
|
||||||
|
},
|
||||||
|
CatalogScriptAuthority {
|
||||||
|
size: authority.size + 1,
|
||||||
|
..authority
|
||||||
|
},
|
||||||
|
CatalogScriptAuthority {
|
||||||
|
blake3: Blake3Digest::from_bytes([2; 32]),
|
||||||
|
..authority
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
require_unchanged_authority(ElevatedScriptRole::Game, authority, changed).is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_index_binds_worker_to_the_built_catalog() {
|
||||||
|
let manifest = manifest_with_scripts();
|
||||||
|
let index = CatalogContentIndex::from_manifests([&manifest])
|
||||||
|
.expect("embedded index fixture should validate");
|
||||||
|
let authority = catalog_script_authority(&manifest, ElevatedScriptRole::Game)
|
||||||
|
.expect("catalog lookup should succeed")
|
||||||
|
.expect("game script should exist");
|
||||||
|
assert!(require_embedded_catalog_authority(&index, "game", authority).is_ok());
|
||||||
|
assert!(require_embedded_catalog_authority(&index, "unknown", authority).is_err());
|
||||||
|
assert!(
|
||||||
|
require_embedded_catalog_authority(
|
||||||
|
&index,
|
||||||
|
"game",
|
||||||
|
CatalogScriptAuthority {
|
||||||
|
content_id: ContentId::from_bytes([3; 32]),
|
||||||
|
..authority
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn script_digest_verification_uses_exact_size_and_bytes() {
|
||||||
|
use std::io::Read as _;
|
||||||
|
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"lanspread-elevated-script-digest-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.expect("clock should follow epoch")
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
let bytes = b"@echo off\r\n";
|
||||||
|
std::fs::write(&path, bytes).expect("test script should write");
|
||||||
|
let authority = CatalogScriptAuthority {
|
||||||
|
content_id: ContentId::from_bytes([7; 32]),
|
||||||
|
size: u64::try_from(bytes.len()).expect("script length should fit u64"),
|
||||||
|
blake3: Blake3Digest::hash(bytes),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut file = std::fs::File::open(&path).expect("test script should open");
|
||||||
|
verify_script_digest(&mut file, &path, authority).expect("exact script should verify");
|
||||||
|
let mut reread = Vec::new();
|
||||||
|
file.read_to_end(&mut reread)
|
||||||
|
.expect("verified handle should be rewound");
|
||||||
|
assert_eq!(reread, bytes);
|
||||||
|
|
||||||
|
let mut changed = std::fs::File::open(&path).expect("test script should reopen");
|
||||||
|
let error = verify_script_digest(
|
||||||
|
&mut changed,
|
||||||
|
&path,
|
||||||
|
CatalogScriptAuthority {
|
||||||
|
blake3: Blake3Digest::from_bytes([8; 32]),
|
||||||
|
..authority
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect_err("wrong digest must fail");
|
||||||
|
assert!(error.contains("BLAKE3 mismatch"));
|
||||||
|
let mut wrong_size = std::fs::File::open(&path).expect("test script should reopen");
|
||||||
|
let error = verify_script_digest(
|
||||||
|
&mut wrong_size,
|
||||||
|
&path,
|
||||||
|
CatalogScriptAuthority {
|
||||||
|
size: authority.size + 1,
|
||||||
|
..authority
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect_err("wrong size must fail");
|
||||||
|
assert!(error.contains("size mismatch"));
|
||||||
|
std::fs::remove_file(path).expect("test script should clean up");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[test]
|
||||||
|
fn verified_script_lock_blocks_mutation_and_path_replacement() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"lanspread-elevated-script-lock-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.expect("clock should follow epoch")
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
let game = root.join("game");
|
||||||
|
std::fs::create_dir_all(game.join(INSTALLED_GAME_DIRECTORY))
|
||||||
|
.expect("installed game directory should be created");
|
||||||
|
std::fs::write(
|
||||||
|
game.join(INSTALLED_GAME_DIRECTORY)
|
||||||
|
.join(INSTALL_OWNED_MARKER),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.expect("install ownership marker should be created");
|
||||||
|
let script_path = game.join(crate::GAME_START_SCRIPT);
|
||||||
|
let script = b"@echo off\r\n";
|
||||||
|
std::fs::write(&script_path, script).expect("test script should be created");
|
||||||
|
let authority = CatalogScriptAuthority {
|
||||||
|
content_id: ContentId::from_bytes([7; 32]),
|
||||||
|
size: u64::try_from(script.len()).expect("script length should fit u64"),
|
||||||
|
blake3: Blake3Digest::hash(script),
|
||||||
|
};
|
||||||
|
|
||||||
|
let locked = LockedCatalogScript::open_and_verify(
|
||||||
|
&root,
|
||||||
|
"game",
|
||||||
|
ElevatedScriptRole::Game,
|
||||||
|
authority,
|
||||||
|
)
|
||||||
|
.expect("trusted script should lock and verify");
|
||||||
|
assert!(
|
||||||
|
std::fs::read(&script_path).is_ok(),
|
||||||
|
"read sharing must remain compatible"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
std::fs::write(&script_path, b"attacker").is_err(),
|
||||||
|
"write sharing must be denied"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
std::fs::rename(&script_path, game.join("replacement.cmd")).is_err(),
|
||||||
|
"script replacement must be denied"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
std::fs::rename(&game, root.join("replacement-game")).is_err(),
|
||||||
|
"game-root replacement must be denied"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut child = std::process::Command::new(
|
||||||
|
system_command_processor().expect("system command processor should resolve"),
|
||||||
|
)
|
||||||
|
.args(["/d", "/c", "set /p lanspread_lock_test="])
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.expect("lock-holder child should start");
|
||||||
|
locked
|
||||||
|
.duplicate_locks_into(&child)
|
||||||
|
.expect("lock handles should transfer to child");
|
||||||
|
drop(locked);
|
||||||
|
assert!(
|
||||||
|
std::fs::write(&script_path, b"attacker").is_err(),
|
||||||
|
"child-owned script handle must retain the write exclusion"
|
||||||
|
);
|
||||||
|
drop(child.stdin.take());
|
||||||
|
let _status = child.wait().expect("lock-holder child should settle");
|
||||||
|
std::fs::write(&script_path, script)
|
||||||
|
.expect("script should become writable after child exit");
|
||||||
|
std::fs::remove_dir_all(root).expect("test fixture should clean up after lock release");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shell_parameter_quoting_preserves_spaces_quotes_and_trailing_slashes() {
|
||||||
|
assert_eq!(quote_windows_argument("plain"), "\"plain\"");
|
||||||
|
assert_eq!(
|
||||||
|
quote_windows_argument("C:\\Games With Spaces\\"),
|
||||||
|
"\"C:\\Games With Spaces\\\\\""
|
||||||
|
);
|
||||||
|
assert_eq!(quote_windows_argument("a\"b"), "\"a\\\"b\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_parameters_carry_only_fixed_role_and_catalog_claims() {
|
||||||
|
let manifest = manifest_with_scripts();
|
||||||
|
let authority = catalog_script_authority(&manifest, ElevatedScriptRole::Server)
|
||||||
|
.expect("catalog lookup should succeed")
|
||||||
|
.expect("server script should exist");
|
||||||
|
let parameters = worker_parameters(
|
||||||
|
ElevatedScriptRole::Server,
|
||||||
|
Path::new("C:\\Games With Spaces"),
|
||||||
|
"game",
|
||||||
|
"de",
|
||||||
|
"Jörg",
|
||||||
|
authority,
|
||||||
|
)
|
||||||
|
.expect("worker parameters should encode");
|
||||||
|
|
||||||
|
assert!(parameters.contains("\"server\""));
|
||||||
|
assert!(parameters.contains("\"C:\\Games With Spaces\""));
|
||||||
|
let content_id = authority.content_id.to_string();
|
||||||
|
let digest = authority.blake3.to_string();
|
||||||
|
assert!(parameters.contains(content_id.as_str()));
|
||||||
|
assert!(parameters.contains(digest.as_str()));
|
||||||
|
assert!(!parameters.contains("cmd.exe"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user