refactor(peer): centralize game root path policy

Game scanning, manifest validation, install recovery, migration, and download
cleanup each carried their own spellings and case rules for reserved entries.
Those copies had already diverged, which made it possible for one subsystem to
accept or expose a path that another treated as application-owned state.

Introduce one game_paths module for the canonical names and conservative
portable comparison policy. Keep context-specific predicates for manifest and
scanner protection versus cancellation preservation: cancellation still sweeps
its own version transaction scratch files, while install and migration state
survive. Reuse the constants for all production path construction sites.

Test Plan:
- `just test` -- passed (175 lanspread-peer tests and full workspace)
- `just clippy` -- passed
- `just fmt` -- Rust, TOML, and Prettier completed; the recipe remains blocked
  by 39 pre-existing rumdl issues in five unrelated Markdown files
- `git diff --cached --check` -- passed
This commit is contained in:
ddidderr committed 2026-08-09 17:59:01 +02:00
1 parent a6ed60a538
commit a1013b028d
13 files changed
+198 -129

No files matched your search

+5 -24
View File
@@ -7,6 +7,8 @@ use std::{
use eyre::WrapErr;
use lanspread_db::db::{GameCatalog, GameFileDescription};
use crate::game_paths::{VERSION_INI, is_download_protected_root_name, portable_name_key};
/// A remote manifest may describe at most this many filesystem entries.
pub(crate) const MAX_DOWNLOAD_MANIFEST_ENTRIES: usize = 100_000;
/// A single remotely described file may be at most one tebibyte.
@@ -21,8 +23,6 @@ pub(crate) const MAX_DOWNLOAD_COMPONENT_BYTES: usize = 255;
pub(crate) const MAX_DOWNLOAD_RELATIVE_PATH_BYTES: usize = 900;
const MAX_DOWNLOAD_DESTINATION_UNITS: usize = 1_000;
const VERSION_INI: &str = "version.ini";
/// One entry whose path and shape were validated as part of a complete manifest.
#[derive(Clone, Debug)]
pub(crate) struct ValidatedDownloadEntry {
@@ -259,7 +259,7 @@ impl<'a> ProtocolV7ManifestBuilder<'a> {
if windows_alias_component(root_component)? == self.game_alias {
eyre::bail!("download path contains a doubled game prefix: {display_path}");
}
if is_protected_root_component(root_component) {
if is_download_protected_root_name(root_component) {
eyre::bail!("download path targets install or recovery state: {display_path}");
}
Ok(())
@@ -337,7 +337,7 @@ fn validate_game_id(game_id: &str) -> eyre::Result<()> {
eyre::bail!("catalog game ID must be one path component: {game_id}");
}
validate_component(game_id)?;
if is_protected_root_component(game_id) {
if is_download_protected_root_name(game_id) {
eyre::bail!("catalog game ID is reserved for application state: {game_id}");
}
Ok(())
@@ -447,7 +447,7 @@ fn validate_component(component: &str) -> eyre::Result<()> {
fn windows_alias_component(component: &str) -> eyre::Result<String> {
validate_component(component)?;
Ok(component.to_uppercase())
Ok(portable_name_key(component))
}
fn is_windows_device_name(stem: &str) -> bool {
@@ -472,25 +472,6 @@ fn looks_like_dos_short_name(component: &str) -> bool {
})
}
fn is_protected_root_component(component: &str) -> bool {
let alias = component.to_uppercase();
alias == "LOCAL"
|| alias.starts_with(".LOCAL.")
|| alias.starts_with(".VERSION.INI.")
|| matches!(
alias.as_str(),
".SYNC"
| ".LANSPREAD"
| ".LANSPREAD.JSON"
| ".LANSPREAD.JSON.TMP"
| ".LANSPREAD_OWNED"
| ".SOFTLAN_FIRST_START_DONE"
| ".SOFTLAN_GAME_INSTALLED"
| "INSTALL_INTENT.JSON"
| "INSTALL_INTENT.JSON.TMP"
)
}
fn validate_entry_shape(
shapes: &mut BTreeMap<String, EntryShape>,
alias_path: &str,
@@ -3,9 +3,7 @@ use std::{io::ErrorKind, path::Path};
use tokio::fs::OpenOptions;
use super::manifest::ValidatedDownloadManifest;
use crate::local_games::is_local_dir_name;
const SYNC_DIR: &str = ".sync";
use crate::game_paths::is_preserved_on_download_discard;
/// Prepares storage for game files by creating directories and pre-allocating files.
pub(super) async fn prepare_game_storage(manifest: &ValidatedDownloadManifest) -> eyre::Result<()> {
@@ -90,7 +88,7 @@ pub(super) async fn discard_cancelled_download(
}
fn should_preserve_on_download_discard(name: &str) -> bool {
is_local_dir_name(name) || name.starts_with(".local.") || name == SYNC_DIR
is_preserved_on_download_discard(name)
}
async fn remove_entry(path: &Path) -> eyre::Result<()> {
@@ -3,6 +3,8 @@ use std::path::Path;
use lanspread_db::db::GameFileDescription;
use tokio::{io::AsyncWriteExt, sync::Mutex};
use crate::game_paths::{VERSION_DISCARDED_FILE, VERSION_INI, VERSION_TMP_FILE};
#[derive(Debug)]
pub(super) struct VersionIniBuffer {
relative_path: String,
@@ -48,27 +50,27 @@ impl VersionIniBuffer {
pub(super) async fn begin_version_ini_transaction(game_root: &Path) -> eyre::Result<()> {
tokio::fs::create_dir_all(game_root).await?;
remove_file_if_exists(&game_root.join(".version.ini.tmp")).await?;
remove_file_if_exists(&game_root.join(".version.ini.discarded")).await?;
remove_file_if_exists(&game_root.join(VERSION_TMP_FILE)).await?;
remove_file_if_exists(&game_root.join(VERSION_DISCARDED_FILE)).await?;
let version_path = game_root.join("version.ini");
let version_path = game_root.join(VERSION_INI);
if tokio::fs::metadata(&version_path)
.await
.is_ok_and(|metadata| metadata.is_file())
{
tokio::fs::rename(version_path, game_root.join(".version.ini.discarded")).await?;
tokio::fs::rename(version_path, game_root.join(VERSION_DISCARDED_FILE)).await?;
}
Ok(())
}
pub(super) async fn rollback_version_ini_transaction(game_root: &Path) {
if let Err(err) = remove_file_if_exists(&game_root.join(".version.ini.tmp")).await {
if let Err(err) = remove_file_if_exists(&game_root.join(VERSION_TMP_FILE)).await {
log::warn!(
"Failed to sweep partial version.ini tmp in {}: {err}",
game_root.display()
);
}
if let Err(err) = remove_file_if_exists(&game_root.join(".version.ini.discarded")).await {
if let Err(err) = remove_file_if_exists(&game_root.join(VERSION_DISCARDED_FILE)).await {
log::warn!(
"Failed to sweep discarded version.ini in {}: {err}",
game_root.display()
@@ -80,8 +82,8 @@ pub(super) async fn commit_version_ini_buffer(
game_root: &Path,
buffer: &VersionIniBuffer,
) -> eyre::Result<()> {
let tmp_path = game_root.join(".version.ini.tmp");
let version_path = game_root.join("version.ini");
let tmp_path = game_root.join(VERSION_TMP_FILE);
let version_path = game_root.join(VERSION_INI);
let bytes = buffer.snapshot().await;
let mut file = tokio::fs::File::create(&tmp_path).await?;
@@ -91,7 +93,7 @@ pub(super) async fn commit_version_ini_buffer(
tokio::fs::rename(&tmp_path, &version_path).await?;
sync_parent_dir(&version_path)?;
remove_file_if_exists(&game_root.join(".version.ini.discarded")).await?;
remove_file_if_exists(&game_root.join(VERSION_DISCARDED_FILE)).await?;
Ok(())
}