//! Retained no-follow authority for install mutations below one game root. use std::{ collections::BTreeMap, ffi::OsStr, fmt, fs::File, io::ErrorKind, path::{Component, Path, PathBuf}, }; use cap_fs_ext::{ FollowSymlinks, OpenOptionsFollowExt, OpenOptionsMaybeDirExt, OpenOptionsSyncExt, }; use cap_primitives::{ ambient_authority, fs::{self, DirOptions, OpenOptions}, }; use lanspread_db::content_manifest::{ CatalogEntryKind, CatalogExtractedEntry, MAX_CATALOG_ENTRIES, }; use tokio_util::sync::CancellationToken; use unicode_normalization::is_nfc; use crate::game_paths::{ INSTALL_OWNED_MARKER, INSTALLING_DIR, LOCAL_DIR, is_download_protected_root_name, }; // Extracted catalogs contain at most 100,000 explicit entries. Permit generous // implicit-parent expansion without allowing a manifest to allocate or walk an // impractically large directory plan. const MAX_STAGING_SYNC_ENTRIES: usize = 1_000_001; /// Open authority for exactly one direct game directory. /// /// The handles are deliberately retained even though the current installer /// still passes the ambient display path to the unpacker. Keeping them alive /// establishes that both the configured directory and its direct child were /// opened without following their final path components for the transaction's /// full lifetime. #[derive(Debug)] pub(super) struct MutationGameRoot { _games_folder: File, game_root: File, display_path: PathBuf, created: bool, } #[derive(Debug)] pub(super) enum StagingPromotionOutcome { Durable, /// The rename is visible, but its parent-directory flush failed. Callers /// must run intent recovery (which retries that flush) before publishing. RenamedNeedsRecovery(eyre::Report), } #[derive(Debug)] struct StagingPromotionCancelled { game_root: PathBuf, } impl fmt::Display for StagingPromotionCancelled { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { write!( formatter, "streamed install for {} was cancelled before promotion", self.game_root.display() ) } } impl std::error::Error for StagingPromotionCancelled {} pub(super) fn is_staging_promotion_cancelled(error: &eyre::Report) -> bool { error.downcast_ref::().is_some() } impl MutationGameRoot { pub(super) fn open_or_create_target(game_root: &Path, game_id: &str) -> eyre::Result { validate_exact_target(game_root, game_id)?; let games_folder = game_root .parent() .expect("validated game roots have one direct parent") .to_path_buf(); let game_id = game_id.to_owned(); crate::scoped_blocking::scoped_blocking(move || { Self::open_blocking(&games_folder, &game_id, OpenMode::Create) }) } pub(super) fn open_existing(games_folder: &Path, game_id: &str) -> eyre::Result> { validate_game_id(game_id)?; let games_folder = games_folder.to_path_buf(); let game_id = game_id.to_owned(); match crate::scoped_blocking::scoped_blocking(move || { Self::open_blocking(&games_folder, &game_id, OpenMode::Existing) }) { Ok(root) => Ok(Some(root)), Err(error) if error .downcast_ref::() .is_some_and(|error| error.kind() == ErrorKind::NotFound) => { Ok(None) } Err(error) => Err(error), } } pub(super) fn open_existing_target( game_root: &Path, game_id: &str, ) -> eyre::Result> { validate_exact_target(game_root, game_id)?; let games_folder = game_root .parent() .expect("validated game roots have one direct parent"); Self::open_existing(games_folder, game_id) } fn open_blocking(games_folder: &Path, game_id: &str, mode: OpenMode) -> eyre::Result { let games_dir = open_ambient_directory_nofollow(games_folder)?; let game_component = Path::new(game_id); let (game_root, created) = match fs::open(&games_dir, game_component, &directory_options()) { Ok(root) => (root, false), Err(error) if mode == OpenMode::Create && error.kind() == ErrorKind::NotFound => { let created = match fs::create_dir(&games_dir, game_component, &DirOptions::new()) { Ok(()) => { sync_directory_handle(&games_dir)?; true } Err(error) if error.kind() == ErrorKind::AlreadyExists => false, Err(error) => return Err(error.into()), }; ( fs::open(&games_dir, game_component, &directory_options())?, created, ) } Err(error) => return Err(error.into()), }; validate_directory_handle(&game_root, "game root")?; Ok(Self { _games_folder: games_dir, game_root, display_path: games_folder.join(game_id), created, }) } pub(super) fn display_path(&self) -> &Path { &self.display_path } pub(super) const fn created(&self) -> bool { self.created } /// Flush directory-entry changes below this retained no-follow root. pub(super) fn sync_game_root(&self) -> eyre::Result<()> { crate::scoped_blocking::scoped_blocking(|| { sync_directory_handle(&self.game_root).map_err(Into::into) }) } /// Durably flush a verified Stream Install tree and atomically promote it. /// /// The exact catalog file set is reopened through retained, no-follow /// directory handles after launch-settings mutation, so every final file /// version is flushed. All explicit and implicit directories are then /// flushed deepest-first before the staging rename. The entire boundary is /// finite blocking work: task cancellation or drop cannot detach a sync or /// interleave between the final cancellation check, rename, and parent sync. pub(super) fn sync_and_promote_staging( &self, entries: &[CatalogExtractedEntry], cancel_token: &CancellationToken, ) -> eyre::Result { let plan = StagingSyncPlan::from_catalog(entries)?; crate::scoped_blocking::scoped_blocking(|| { self.sync_and_promote_staging_blocking(&plan, cancel_token) }) } fn sync_and_promote_staging_blocking( &self, plan: &StagingSyncPlan, cancel_token: &CancellationToken, ) -> eyre::Result { self.sync_and_promote_staging_with(plan, cancel_token, &RealStagingDurabilityOps) } fn sync_and_promote_staging_with( &self, plan: &StagingSyncPlan, cancel_token: &CancellationToken, ops: &impl StagingDurabilityOps, ) -> eyre::Result { let staging = open_directory_component(&self.game_root, INSTALLING_DIR)?; let mut seen = 0_usize; sync_verified_directory_tree( &staging, "", plan, &mut seen, cancel_token, &self.display_path, ops, )?; if seen != plan.entries.len() { eyre::bail!( "verified streamed install staging tree has {seen} entries; expected {}", plan.entries.len() ); } reject_cancelled(cancel_token, &self.display_path)?; ops.rename_staging(&self.game_root)?; match ops.sync_directory(&self.game_root, "") { Ok(()) => Ok(StagingPromotionOutcome::Durable), Err(error) => Ok(StagingPromotionOutcome::RenamedNeedsRecovery(error.into())), } } } trait StagingDurabilityOps { fn sync_file(&self, file: &File, relative_path: &str) -> std::io::Result<()>; fn sync_directory(&self, directory: &File, relative_path: &str) -> std::io::Result<()>; fn rename_staging(&self, game_root: &File) -> std::io::Result<()>; } struct RealStagingDurabilityOps; impl StagingDurabilityOps for RealStagingDurabilityOps { fn sync_file(&self, file: &File, _relative_path: &str) -> std::io::Result<()> { file.sync_all() } fn sync_directory(&self, directory: &File, _relative_path: &str) -> std::io::Result<()> { sync_directory_handle(directory) } fn rename_staging(&self, game_root: &File) -> std::io::Result<()> { fs::rename( game_root, Path::new(INSTALLING_DIR), game_root, Path::new(LOCAL_DIR), ) } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum StagingEntryKind { Directory, File, } #[derive(Debug, Eq, PartialEq)] struct StagingSyncPlan { /// Exact catalog entries, their implicit parents, and the transaction marker. entries: BTreeMap, } impl StagingSyncPlan { fn from_catalog(entries: &[CatalogExtractedEntry]) -> eyre::Result { if entries.len() > MAX_CATALOG_ENTRIES { eyre::bail!( "streamed install sync plan exceeds the {MAX_CATALOG_ENTRIES}-entry catalog limit" ); } let mut expected = BTreeMap::new(); for entry in entries { let path = entry.canonical_path().as_str(); let kind = match entry.kind() { CatalogEntryKind::Directory => StagingEntryKind::Directory, CatalogEntryKind::File => StagingEntryKind::File, }; insert_expected_staging_entry(&mut expected, path, kind)?; for (separator, _) in path.match_indices('/') { insert_expected_staging_entry( &mut expected, &path[..separator], StagingEntryKind::Directory, )?; } } insert_expected_staging_entry(&mut expected, INSTALL_OWNED_MARKER, StagingEntryKind::File)?; Ok(Self { entries: expected }) } } fn insert_expected_staging_entry( entries: &mut BTreeMap, path: &str, kind: StagingEntryKind, ) -> eyre::Result<()> { if !entries.contains_key(path) && entries.len() >= MAX_STAGING_SYNC_ENTRIES { eyre::bail!( "streamed install sync plan exceeds the {MAX_STAGING_SYNC_ENTRIES}-entry limit" ); } match entries.insert(path.to_owned(), kind) { Some(previous) if previous != kind => { eyre::bail!("streamed install sync plan changes the shape of {path}"); } Some(_) | None => Ok(()), } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum OpenMode { Existing, Create, } fn validate_exact_target(game_root: &Path, game_id: &str) -> eyre::Result<()> { validate_game_id(game_id)?; let Some(games_folder) = game_root.parent() else { eyre::bail!( "game root has no configured-games-directory parent: {}", game_root.display() ); }; if game_root.file_name() != Some(OsStr::new(game_id)) || games_folder.join(game_id) != game_root { eyre::bail!( "game root is not the requested direct game-id child: {}", game_root.display() ); } Ok(()) } pub(super) fn validate_game_id(game_id: &str) -> eyre::Result<()> { if game_id.is_empty() || game_id.contains(['/', '\\', '\0']) { eyre::bail!("game ID must be one non-empty path component: {game_id:?}"); } if !is_nfc(game_id) { eyre::bail!("game ID must use Unicode NFC normalization: {game_id}"); } let mut components = Path::new(game_id).components(); if !matches!( (components.next(), components.next()), (Some(Component::Normal(component)), None) if component == OsStr::new(game_id) ) { eyre::bail!("game ID must be one normal path component: {game_id}"); } if game_id.ends_with([' ', '.']) { eyre::bail!("game ID has a trailing dot or space: {game_id}"); } if game_id.len() > 255 { eyre::bail!("game ID exceeds the 255-byte portable component limit"); } if game_id.chars().any(|character| { character <= '\u{1f}' || matches!(character, '<' | '>' | ':' | '"' | '|' | '?' | '*') }) { eyre::bail!("game ID is not a portable path component: {game_id}"); } let device_stem = game_id.split('.').next().unwrap_or_default().trim_end(); if is_windows_device_name(device_stem) { eyre::bail!("game ID uses a Windows device name: {game_id}"); } if is_download_protected_root_name(game_id) { eyre::bail!("game ID is reserved for application state: {game_id}"); } Ok(()) } fn is_windows_device_name(stem: &str) -> bool { let upper = stem.to_ascii_uppercase(); matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") || upper .strip_prefix("COM") .or_else(|| upper.strip_prefix("LPT")) .is_some_and(|number| { (number.len() == 1 && number.as_bytes()[0].is_ascii_digit()) || matches!(number, "¹" | "²" | "³") }) } fn open_ambient_directory_nofollow(path: &Path) -> eyre::Result { let directory = fs::open_ambient(path, &directory_options(), ambient_authority())?; validate_directory_handle(&directory, &path.display().to_string())?; Ok(directory) } fn sync_verified_directory_tree( directory: &File, relative_dir: &str, plan: &StagingSyncPlan, seen: &mut usize, cancel_token: &CancellationToken, game_root: &Path, ops: &impl StagingDurabilityOps, ) -> eyre::Result<()> { for entry in fs::read_base_dir(directory)? { reject_cancelled(cancel_token, game_root)?; *seen = seen .checked_add(1) .ok_or_else(|| eyre::eyre!("streamed install staging entry count overflow"))?; if *seen > plan.entries.len() { eyre::bail!( "verified streamed install staging tree contains more than the expected {} entries", plan.entries.len() ); } let entry = entry?; let name = entry.file_name(); let name = name.to_str().ok_or_else(|| { eyre::eyre!("verified streamed install staging tree contains a non-UTF-8 entry") })?; let relative_path = if relative_dir.is_empty() { name.to_owned() } else { format!("{relative_dir}/{name}") }; let expected_kind = plan.entries.get(&relative_path).ok_or_else(|| { eyre::eyre!( "verified streamed install staging tree contains unmanifested entry {relative_path}" ) })?; let component = Path::new(name); match expected_kind { StagingEntryKind::Directory => { let child = open_directory_at(directory, component, &relative_path)?; sync_verified_directory_tree( &child, &relative_path, plan, seen, cancel_token, game_root, ops, )?; } StagingEntryKind::File => { let file = open_regular_file_at(directory, component, &relative_path)?; ops.sync_file(&file, &relative_path)?; } } } reject_cancelled(cancel_token, game_root)?; // On Unix this is the post-order durability barrier for the directory's // children. The non-Unix helper below explicitly documents the portability // gap where Rust has no durable directory-flush primitive. ops.sync_directory(directory, relative_dir)?; Ok(()) } fn reject_cancelled(cancel_token: &CancellationToken, game_root: &Path) -> eyre::Result<()> { if cancel_token.is_cancelled() { return Err(eyre::Report::new(StagingPromotionCancelled { game_root: game_root.to_path_buf(), })); } Ok(()) } fn open_directory_component(parent: &File, component: &str) -> eyre::Result { open_directory_at(parent, Path::new(component), component) } fn open_directory_at(parent: &File, path: &Path, display: &str) -> eyre::Result { let directory = fs::open(parent, path, &directory_options())?; validate_directory_handle(&directory, display)?; Ok(directory) } fn open_regular_file_at(parent: &File, path: &Path, display: &str) -> eyre::Result { let file = fs::open(parent, path, ®ular_file_options())?; validate_regular_file_handle(&file, display)?; Ok(file) } fn directory_options() -> OpenOptions { let mut options = OpenOptions::new(); options.read(true); options .maybe_dir(true) .follow(FollowSymlinks::No) .nonblock(true); options } fn regular_file_options() -> OpenOptions { let mut options = OpenOptions::new(); options.read(true).write(true); options.follow(FollowSymlinks::No).nonblock(true); options } fn validate_directory_handle(file: &File, display: &str) -> std::io::Result<()> { let metadata = file.metadata()?; if !metadata.is_dir() { return Err(std::io::Error::new( ErrorKind::InvalidInput, format!("install mutation target is not a directory: {display}"), )); } reject_windows_reparse(&metadata, display) } fn validate_regular_file_handle(file: &File, display: &str) -> std::io::Result<()> { let metadata = file.metadata()?; if !metadata.is_file() { return Err(std::io::Error::new( ErrorKind::InvalidInput, format!("install mutation target is not a regular file: {display}"), )); } reject_windows_reparse(&metadata, display) } #[cfg(windows)] fn reject_windows_reparse(metadata: &std::fs::Metadata, display: &str) -> std::io::Result<()> { use std::os::windows::fs::MetadataExt as _; const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { return Err(std::io::Error::new( ErrorKind::InvalidInput, format!("install mutation target is a Windows reparse point: {display}"), )); } Ok(()) } #[cfg(not(windows))] #[allow(clippy::unnecessary_wraps)] const fn reject_windows_reparse( _metadata: &std::fs::Metadata, _display: &str, ) -> std::io::Result<()> { Ok(()) } #[cfg(unix)] fn sync_directory_handle(directory: &File) -> std::io::Result<()> { directory.sync_all() } #[cfg(not(unix))] const fn sync_directory_handle(_directory: &File) -> std::io::Result<()> { // Rust does not expose a portable durable directory flush on Windows. Ok(()) } #[cfg(test)] mod tests { use std::cell::{Cell, RefCell}; use super::*; use crate::test_support::TempDir; #[derive(Default)] struct RecordingDurabilityOps { events: RefCell>, fail_file: Option, fail_directory: Option, fail_rename: bool, renamed: Cell, } impl StagingDurabilityOps for RecordingDurabilityOps { fn sync_file(&self, file: &File, relative_path: &str) -> std::io::Result<()> { self.events .borrow_mut() .push(format!("file:{relative_path}")); if self.fail_file.as_deref() == Some(relative_path) { return Err(std::io::Error::other("injected file sync failure")); } file.sync_all() } fn sync_directory(&self, directory: &File, relative_path: &str) -> std::io::Result<()> { self.events .borrow_mut() .push(format!("dir:{relative_path}")); if self.fail_directory.as_deref() == Some(relative_path) { return Err(std::io::Error::other("injected directory sync failure")); } sync_directory_handle(directory) } fn rename_staging(&self, game_root: &File) -> std::io::Result<()> { self.renamed.set(true); if self.fail_rename { return Err(std::io::Error::other("injected rename failure")); } RealStagingDurabilityOps.rename_staging(game_root) } } fn staged_tree() -> (TempDir, MutationGameRoot, Vec) { let games = TempDir::new("lanspread-staging-durability"); let root = games.game_root(); let capability = MutationGameRoot::open_or_create_target(&root, "game").expect("game root should open"); let staging = root.join(INSTALLING_DIR); std::fs::create_dir_all(staging.join("nested/deep")) .expect("nested staging should be created"); std::fs::write(staging.join(INSTALL_OWNED_MARKER), []) .expect("ownership marker should be written"); std::fs::write(staging.join("nested/deep/payload.bin"), b"payload") .expect("payload should be written"); let entries = vec![ CatalogExtractedEntry::file( "nested/deep/payload.bin", 7, lanspread_db::content_manifest::Blake3Digest::hash(b"payload"), ) .expect("catalog entry should validate"), ]; (games, capability, entries) } #[test] fn exact_nested_staging_tree_syncs_files_and_directories_before_rename() { let (_games, capability, entries) = staged_tree(); let plan = StagingSyncPlan::from_catalog(&entries).expect("sync plan should build"); let ops = RecordingDurabilityOps::default(); let outcome = capability .sync_and_promote_staging_with(&plan, &CancellationToken::new(), &ops) .expect("exact nested tree should promote"); assert!(matches!(outcome, StagingPromotionOutcome::Durable)); assert!(ops.renamed.get()); let events = ops.events.borrow(); let payload = events .iter() .position(|event| event == "file:nested/deep/payload.bin") .expect("payload should be synced"); let marker = events .iter() .position(|event| event == "file:.lanspread_owned") .expect("transaction marker should be synced"); let deep = events .iter() .position(|event| event == "dir:nested/deep") .expect("deep directory should be synced"); let nested = events .iter() .position(|event| event == "dir:nested") .expect("parent directory should be synced"); let staging = events .iter() .position(|event| event == "dir:") .expect("staging root should be synced"); assert!(payload < deep && deep < nested && nested < staging); assert!(marker < staging); assert!(capability.display_path().join(LOCAL_DIR).is_dir()); } #[test] fn injected_pre_rename_sync_failures_never_rename_staging() { for (fail_file, fail_directory) in [ (Some("nested/deep/payload.bin".to_owned()), None), (None, Some("nested/deep".to_owned())), ] { let (_games, capability, entries) = staged_tree(); let plan = StagingSyncPlan::from_catalog(&entries).expect("sync plan should build"); let ops = RecordingDurabilityOps { fail_file, fail_directory, ..RecordingDurabilityOps::default() }; let error = capability .sync_and_promote_staging_with(&plan, &CancellationToken::new(), &ops) .expect_err("injected sync failure should fail closed"); assert!(!is_staging_promotion_cancelled(&error)); assert!(!ops.renamed.get()); assert!(capability.display_path().join(INSTALLING_DIR).is_dir()); assert!(!capability.display_path().join(LOCAL_DIR).exists()); } } #[test] fn cancellation_and_unmanifested_entries_prevent_rename() { let (_games, capability, entries) = staged_tree(); let plan = StagingSyncPlan::from_catalog(&entries).expect("sync plan should build"); let cancelled = CancellationToken::new(); cancelled.cancel(); let ops = RecordingDurabilityOps::default(); let error = capability .sync_and_promote_staging_with(&plan, &cancelled, &ops) .expect_err("pre-promote cancellation should fail closed"); assert!(is_staging_promotion_cancelled(&error)); assert!(!ops.renamed.get()); assert!(!capability.display_path().join(LOCAL_DIR).exists()); std::fs::write( capability .display_path() .join(INSTALLING_DIR) .join("extra.bin"), b"extra", ) .expect("extra staging file should be written"); let ops = RecordingDurabilityOps::default(); let _error = capability .sync_and_promote_staging_with(&plan, &CancellationToken::new(), &ops) .expect_err("unmanifested staging file should fail closed"); assert!(!ops.renamed.get()); assert!(!capability.display_path().join(LOCAL_DIR).exists()); } #[test] fn parent_sync_failure_is_phase_aware_and_can_be_retried() { let (_games, capability, entries) = staged_tree(); let plan = StagingSyncPlan::from_catalog(&entries).expect("sync plan should build"); let ops = RecordingDurabilityOps { fail_directory: Some("".to_owned()), ..RecordingDurabilityOps::default() }; let outcome = capability .sync_and_promote_staging_with(&plan, &CancellationToken::new(), &ops) .expect("post-rename sync failure should have a phase-aware outcome"); assert!(matches!( outcome, StagingPromotionOutcome::RenamedNeedsRecovery(_) )); assert!(ops.renamed.get()); assert!(!capability.display_path().join(INSTALLING_DIR).exists()); assert!(capability.display_path().join(LOCAL_DIR).is_dir()); capability .sync_game_root() .expect("later recovery should retry the parent sync"); } #[test] fn rename_failure_leaves_the_exact_synced_tree_in_staging() { let (_games, capability, entries) = staged_tree(); let plan = StagingSyncPlan::from_catalog(&entries).expect("sync plan should build"); let ops = RecordingDurabilityOps { fail_rename: true, ..RecordingDurabilityOps::default() }; let _error = capability .sync_and_promote_staging_with(&plan, &CancellationToken::new(), &ops) .expect_err("rename failure should fail closed"); assert!(ops.renamed.get()); assert!(capability.display_path().join(INSTALLING_DIR).is_dir()); assert!(!capability.display_path().join(LOCAL_DIR).exists()); } #[cfg(unix)] #[test] fn symlink_in_exact_staging_path_is_rejected_without_escape_or_rename() { use std::os::unix::fs::symlink; let (games, capability, entries) = staged_tree(); let outside = TempDir::new("lanspread-staging-durability-outside"); let payload = games .game_root() .join(INSTALLING_DIR) .join("nested/deep/payload.bin"); std::fs::remove_file(&payload).expect("payload should be removed"); std::fs::write(outside.path().join("canary"), b"outside") .expect("outside canary should be written"); symlink(outside.path().join("canary"), &payload) .expect("staging symlink should be created"); let plan = StagingSyncPlan::from_catalog(&entries).expect("sync plan should build"); let ops = RecordingDurabilityOps::default(); let _error = capability .sync_and_promote_staging_with(&plan, &CancellationToken::new(), &ops) .expect_err("staging symlink should fail closed"); assert!(!ops.renamed.get()); assert_eq!( std::fs::read(outside.path().join("canary")).expect("canary should remain readable"), b"outside" ); assert!(!capability.display_path().join(LOCAL_DIR).exists()); } #[test] fn creates_exact_direct_game_root() { let games = TempDir::new("lanspread-mutation-root"); let root = games.path().join("game"); let capability = MutationGameRoot::open_or_create_target(&root, "game") .expect("direct game root should open"); assert!(capability.created()); assert_eq!(capability.display_path(), root); assert!(root.is_dir()); } #[test] fn rejects_non_component_ids_without_mutation() { let games = TempDir::new("lanspread-mutation-root-invalid-id"); let error = MutationGameRoot::open_or_create_target( &games.path().join("outside").join("game"), "../game", ) .expect_err("traversal ID should fail"); assert!(error.to_string().contains("one non-empty path component")); assert!( std::fs::read_dir(games.path()) .expect("games directory should remain readable") .next() .is_none() ); } #[cfg(unix)] #[test] fn rejects_symlink_game_root() { use std::os::unix::fs::symlink; let games = TempDir::new("lanspread-mutation-root-games"); let outside = TempDir::new("lanspread-mutation-root-outside"); symlink(outside.path(), games.path().join("game")) .expect("game-root symlink should be created"); let error = MutationGameRoot::open_or_create_target(&games.path().join("game"), "game") .expect_err("symlink game root should fail closed"); assert!(!error.to_string().is_empty()); } #[cfg(windows)] #[test] fn rejects_symlink_reparse_game_root_when_supported() { use std::os::windows::fs::symlink_dir; let games = TempDir::new("lanspread-mutation-root-games"); let outside = TempDir::new("lanspread-mutation-root-outside"); if let Err(error) = symlink_dir(outside.path(), games.path().join("game")) { if error.kind() == ErrorKind::PermissionDenied { return; } panic!("game-root reparse fixture should be created: {error}"); } let error = MutationGameRoot::open_or_create_target(&games.path().join("game"), "game") .expect_err("Windows reparse game root should fail closed"); assert!(!error.to_string().is_empty()); } }