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