diff --git a/Cargo.lock b/Cargo.lock index 145fd9a..452764c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2247,6 +2247,7 @@ name = "lanspread-tauri-deno-ts" version = "0.1.0" dependencies = [ "base64 0.23.1", + "blake3", "cap-fs-ext", "cap-primitives", "eyre", diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml b/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml index 71b35f3..d835711 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml +++ b/crates/lanspread-tauri-deno-ts/src-tauri/Cargo.toml @@ -24,6 +24,7 @@ lanspread-peer = { path = "../../lanspread-peer" } # external base64 = { workspace = true } +blake3 = { workspace = true } cap-fs-ext = { workspace = true } cap-primitives = { workspace = true } eyre = { workspace = true } @@ -53,7 +54,14 @@ tauri-build = { version = "2", features = [] } tokio = { workspace = true } [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] needless_pass_by_value = "allow" diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/build.rs b/crates/lanspread-tauri-deno-ts/src-tauri/build.rs index b0f0c17..433c0ec 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/build.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/build.rs @@ -1,4 +1,8 @@ -use std::{env, fs}; +use std::{ + env, + fs, + path::{Path, PathBuf}, +}; use build_support::catalog_gate::{ CatalogBuildMode, @@ -13,6 +17,9 @@ mod build_support { } 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() { println!("cargo:rerun-if-env-changed=TAURI_CONFIG"); @@ -29,9 +36,27 @@ fn main() { { 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(); } +fn embed_catalog_content_index(mode: CatalogBuildMode) -> Result<(), Box> { + 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> { let base_config = fs::read_to_string("tauri.conf.json")?; let config_override = env::var_os("TAURI_CONFIG") diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs index 20d261c..3ce3445 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/lib.rs @@ -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 { + 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 { 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 = OsStr::new(file).encode_wide().chain(Some(0)).collect(); - let params_wide: Vec = OsStr::new(params).encode_wide().chain(Some(0)).collect(); - let dir_wide: Vec = OsStr::new(dir).encode_wide().chain(Some(0)).collect(); - let runas_wide: Vec = 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::>(); - let params_wide = OsStr::new(params) - .encode_wide() - .chain(Some(0)) - .collect::>(); - let dir_wide = OsStr::new(dir) - .encode_wide() - .chain(Some(0)) - .collect::>(); + let file_wide = file.encode_wide().chain(Some(0)).collect::>(); + let params_wide = params.encode_wide().chain(Some(0)).collect::>(); + let dir_wide = dir.encode_wide().chain(Some(0)).collect::>(); 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, 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!( diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/main.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/main.rs index 8a1ebd7..894e807 100644 --- a/crates/lanspread-tauri-deno-ts/src-tauri/src/main.rs +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/main.rs @@ -7,5 +7,10 @@ use mimalloc::MiMalloc; static GLOBAL: MiMalloc = MiMalloc; 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(); } diff --git a/crates/lanspread-tauri-deno-ts/src-tauri/src/windows_launch.rs b/crates/lanspread-tauri-deno-ts/src-tauri/src/windows_launch.rs new file mode 100644 index 0000000..bc63a99 --- /dev/null +++ b/crates/lanspread-tauri-deno-ts/src-tauri/src/windows_launch.rs @@ -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 { + 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, 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 { + 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::>() + .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, +) -> Result, 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, + label: &str, +) -> Result { + 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 { + 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::()) + .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, + // 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 { + 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, 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 { + 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 { + 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 { + 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")); + } +}