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)
868 lines
30 KiB
Rust
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, ®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<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());
|
|
}
|
|
}
|