Files
lanspread/crates/lanspread-peer/src/install/mutation_root.rs
T
ddidderr 76eec55103 fix(paths): accept literal tilde-digit filenames
Catalog generation and peer install/download validation rejected any path component containing a tilde followed by digits, even when the component was a valid long filename such as Bosons TD Gold~1.w3m. Remove the heuristic from all three validators and retain the existing device-name and portable-alias checks. Add a regression test for the literal filename so catalog publication and later path validation agree.

Test Plan:
- just clippy (passed)
- just test (passed)
- git diff --check (passed)
2026-08-20 08:38:36 +02:00

868 lines
30 KiB
Rust

//! 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::<StagingPromotionCancelled>().is_some()
}
impl MutationGameRoot {
pub(super) fn open_or_create_target(game_root: &Path, game_id: &str) -> eyre::Result<Self> {
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<Option<Self>> {
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::<std::io::Error>()
.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<Option<Self>> {
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<Self> {
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<StagingPromotionOutcome> {
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<StagingPromotionOutcome> {
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<StagingPromotionOutcome> {
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, "<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<String, StagingEntryKind>,
}
impl StagingSyncPlan {
fn from_catalog(entries: &[CatalogExtractedEntry]) -> eyre::Result<Self> {
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<String, StagingEntryKind>,
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<File> {
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<File> {
open_directory_at(parent, Path::new(component), component)
}
fn open_directory_at(parent: &File, path: &Path, display: &str) -> eyre::Result<File> {
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<File> {
let file = fs::open(parent, path, &regular_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<Vec<String>>,
fail_file: Option<String>,
fail_directory: Option<String>,
fail_rename: bool,
renamed: Cell<bool>,
}
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<CatalogExtractedEntry>) {
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("<game-root>".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());
}
}