fix(tauri): bind elevated scripts to catalog authority
Preserve required UAC elevation for game_setup.cmd, game_start.cmd, and server_start.cmd through a fixed-role elevated launcher worker. The worker reloads and matches embedded catalog authority, verifies the exact script from a no-follow locked handle, resolves System32 cmd.exe, and transfers path locks into the command process. Setup still waits for completion; game and server return after the verified handoff while their command process retains the locks. Unmanifested, changed, reparse-backed, markerless, and streamed-only scripts fail closed. Test Plan: - just test - just clippy - just frontend-test - just build-fixture - nine Linux-visible authority/parser/digest tests - Windows-only lock-transfer test added but not run (no Windows target/runtime available) - git diff --check
This commit is contained in:
Generated
+1
@@ -2247,6 +2247,7 @@ name = "lanspread-tauri-deno-ts"
|
||||
version = "0.1.0"
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user