Files
lanspread/crates/lanspread-peer/src/download/manifest.rs
T
ddidderr a1013b028d 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
2026-08-09 17:59:01 +02:00

993 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::{
collections::BTreeMap,
fs::Metadata,
path::{Path, PathBuf},
};
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.
pub(crate) const MAX_DOWNLOAD_FILE_BYTES: u64 = 1024 * 1024 * 1024 * 1024;
/// A complete remotely described game may be at most sixteen tebibytes.
pub(crate) const MAX_DOWNLOAD_MANIFEST_BYTES: u64 = 16 * MAX_DOWNLOAD_FILE_BYTES;
/// The root sentinel is parsed in memory and should contain only a version value.
pub(crate) const MAX_VERSION_INI_BYTES: u64 = 64 * 1024;
/// Portable filesystems support at least 255 bytes per ordinary component.
pub(crate) const MAX_DOWNLOAD_COMPONENT_BYTES: usize = 255;
/// Leave room for the configured game root under conservative 1,024-unit paths.
pub(crate) const MAX_DOWNLOAD_RELATIVE_PATH_BYTES: usize = 900;
const MAX_DOWNLOAD_DESTINATION_UNITS: usize = 1_000;
/// One entry whose path and shape were validated as part of a complete manifest.
#[derive(Clone, Debug)]
pub(crate) struct ValidatedDownloadEntry {
canonical_path: String,
protocol_path: String,
is_dir: bool,
size: u64,
}
impl ValidatedDownloadEntry {
pub(crate) fn canonical_path(&self) -> &str {
&self.canonical_path
}
#[cfg(test)]
fn protocol_path(&self) -> &str {
&self.protocol_path
}
pub(crate) const fn is_dir(&self) -> bool {
self.is_dir
}
pub(crate) const fn size(&self) -> u64 {
self.size
}
pub(crate) fn is_version_ini(&self) -> bool {
self.canonical_path == VERSION_INI
}
pub(crate) fn protocol_description(&self, game_id: &str) -> GameFileDescription {
GameFileDescription {
game_id: game_id.to_owned(),
relative_path: self.protocol_path.clone(),
is_dir: self.is_dir,
size: self.size,
}
}
}
/// A complete download description validated before any filesystem mutation.
#[derive(Clone, Debug)]
pub(crate) struct ValidatedDownloadManifest {
game_id: String,
games_folder: PathBuf,
game_root: PathBuf,
entries: Vec<ValidatedDownloadEntry>,
}
impl ValidatedDownloadManifest {
/// Contains current protocol-7 descriptions within one known catalog game root.
pub(crate) fn from_protocol_v7(
games_folder: &Path,
game_id: &str,
descriptions: Vec<GameFileDescription>,
catalog: &GameCatalog,
) -> eyre::Result<Self> {
if !catalog.contains(game_id) {
eyre::bail!("cannot download unknown catalog game {game_id}");
}
if descriptions.len() > MAX_DOWNLOAD_MANIFEST_ENTRIES {
eyre::bail!(
"download manifest for {game_id} has {} entries; limit is {MAX_DOWNLOAD_MANIFEST_ENTRIES}",
descriptions.len()
);
}
validate_game_id(game_id)?;
let games_folder = canonical_games_folder(games_folder)?;
let game_root = games_folder.join(game_id);
validate_game_root(&game_root)?;
let mut builder =
ProtocolV7ManifestBuilder::new(game_id, Some(&game_root), descriptions.len())?;
for description in descriptions {
builder.push(description)?;
}
let entries = builder.finish()?;
Ok(Self {
game_id: game_id.to_owned(),
games_folder,
game_root,
entries,
})
}
pub(crate) fn game_id(&self) -> &str {
&self.game_id
}
pub(crate) fn games_folder(&self) -> &Path {
&self.games_folder
}
pub(crate) fn game_root(&self) -> &Path {
&self.game_root
}
#[cfg(test)]
fn entries(&self) -> &[ValidatedDownloadEntry] {
&self.entries
}
pub(crate) fn transfer_entries(&self) -> impl Iterator<Item = &ValidatedDownloadEntry> {
self.entries.iter().filter(|entry| !entry.is_version_ini())
}
pub(crate) fn protocol_descriptions(&self) -> Vec<GameFileDescription> {
self.entries
.iter()
.map(|entry| entry.protocol_description(&self.game_id))
.collect()
}
}
/// Validates one peer's complete current-wire description before aggregation.
pub(crate) fn validate_protocol_v7_descriptions(
game_id: &str,
descriptions: Vec<GameFileDescription>,
) -> eyre::Result<Vec<GameFileDescription>> {
if descriptions.len() > MAX_DOWNLOAD_MANIFEST_ENTRIES {
eyre::bail!(
"download manifest for {game_id} has {} entries; limit is {MAX_DOWNLOAD_MANIFEST_ENTRIES}",
descriptions.len()
);
}
validate_game_id(game_id)?;
let mut builder = ProtocolV7ManifestBuilder::new(game_id, None, descriptions.len())?;
for description in descriptions {
builder.push(description)?;
}
Ok(builder
.finish()?
.into_iter()
.map(|entry| entry.protocol_description(game_id))
.collect())
}
struct ProtocolV7ManifestBuilder<'a> {
game_id: &'a str,
game_root: Option<&'a Path>,
prefix: String,
game_alias: String,
entries: Vec<ValidatedDownloadEntry>,
shapes: BTreeMap<String, EntryShape>,
total_bytes: u64,
saw_protocol_root: bool,
}
impl<'a> ProtocolV7ManifestBuilder<'a> {
fn new(game_id: &'a str, game_root: Option<&'a Path>, capacity: usize) -> eyre::Result<Self> {
Ok(Self {
game_id,
game_root,
prefix: format!("{game_id}/"),
game_alias: windows_alias_component(game_id)?,
entries: Vec::with_capacity(capacity),
shapes: BTreeMap::new(),
total_bytes: 0,
saw_protocol_root: false,
})
}
fn push(&mut self, description: GameFileDescription) -> eyre::Result<()> {
validate_protocol_game_id(self.game_id, &description)?;
if self.take_redundant_root(&description)? {
return Ok(());
}
if description.relative_path.contains('\\') {
eyre::bail!(
"download path must use forward slashes: {}",
description.relative_path
);
}
let canonical_path = description
.relative_path
.strip_prefix(&self.prefix)
.ok_or_else(|| {
eyre::eyre!(
"download path must start with exactly {}: {}",
self.prefix,
description.relative_path
)
})?;
let (alias_path, components) = validate_canonical_path(canonical_path)?;
self.validate_root_component(&components, &description.relative_path)?;
validate_entry_shape(
&mut self.shapes,
&alias_path,
description.is_dir,
&description.relative_path,
)?;
self.account_size(canonical_path, &description)?;
if let Some(game_root) = self.game_root {
validate_existing_destination(
game_root,
&components,
description.is_dir,
&description.relative_path,
)?;
}
self.entries.push(ValidatedDownloadEntry {
canonical_path: canonical_path.to_owned(),
protocol_path: description.relative_path,
is_dir: description.is_dir,
size: description.size,
});
Ok(())
}
fn take_redundant_root(&mut self, description: &GameFileDescription) -> eyre::Result<bool> {
if description.relative_path != self.game_id {
return Ok(false);
}
if description.is_dir && description.size == 0 {
if self.saw_protocol_root {
eyre::bail!("duplicate protocol game-root entry for {}", self.game_id);
}
self.saw_protocol_root = true;
return Ok(true);
}
eyre::bail!(
"the protocol game-root entry for {} must be a zero-sized directory",
self.game_id
)
}
fn validate_root_component(&self, components: &[&str], display_path: &str) -> eyre::Result<()> {
let root_component = components
.first()
.expect("validated canonical paths have one component");
if windows_alias_component(root_component)? == self.game_alias {
eyre::bail!("download path contains a doubled game prefix: {display_path}");
}
if is_download_protected_root_name(root_component) {
eyre::bail!("download path targets install or recovery state: {display_path}");
}
Ok(())
}
fn account_size(
&mut self,
canonical_path: &str,
description: &GameFileDescription,
) -> eyre::Result<()> {
if description.is_dir {
if description.size != 0 {
eyre::bail!(
"directory entry has a non-zero size: {}",
description.relative_path
);
}
return Ok(());
}
if description.size > MAX_DOWNLOAD_FILE_BYTES {
eyre::bail!(
"download file exceeds the {MAX_DOWNLOAD_FILE_BYTES}-byte limit: {}",
description.relative_path
);
}
if canonical_path == VERSION_INI && description.size > MAX_VERSION_INI_BYTES {
eyre::bail!(
"root version.ini exceeds the {MAX_VERSION_INI_BYTES}-byte limit: {}",
description.relative_path
);
}
self.total_bytes = self
.total_bytes
.checked_add(description.size)
.ok_or_else(|| eyre::eyre!("download manifest byte count overflow"))?;
if self.total_bytes > MAX_DOWNLOAD_MANIFEST_BYTES {
eyre::bail!("download manifest exceeds the {MAX_DOWNLOAD_MANIFEST_BYTES}-byte limit");
}
Ok(())
}
fn finish(mut self) -> eyre::Result<Vec<ValidatedDownloadEntry>> {
self.entries
.sort_by(|left, right| left.canonical_path.cmp(&right.canonical_path));
let versions = self
.entries
.iter()
.filter(|entry| entry.is_version_ini())
.collect::<Vec<_>>();
let [version_ini] = versions.as_slice() else {
eyre::bail!(
"expected exactly one regular root version.ini for {}, found {}",
self.game_id,
versions.len()
);
};
if version_ini.is_dir {
eyre::bail!(
"root version.ini for {} must be a regular file",
self.game_id
);
}
Ok(self.entries)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum EntryShape {
File,
Directory,
}
fn validate_game_id(game_id: &str) -> eyre::Result<()> {
if game_id.contains('/') || game_id.contains('\\') {
eyre::bail!("catalog game ID must be one path component: {game_id}");
}
validate_component(game_id)?;
if is_download_protected_root_name(game_id) {
eyre::bail!("catalog game ID is reserved for application state: {game_id}");
}
Ok(())
}
fn validate_protocol_game_id(
requested_game_id: &str,
description: &GameFileDescription,
) -> eyre::Result<()> {
if description.game_id != requested_game_id {
eyre::bail!(
"description for {} cannot be used to download {requested_game_id}",
description.game_id
);
}
Ok(())
}
fn canonical_games_folder(games_folder: &Path) -> eyre::Result<PathBuf> {
if !games_folder.is_absolute() {
eyre::bail!(
"configured games directory must be absolute: {}",
games_folder.display()
);
}
let canonical = std::fs::canonicalize(games_folder).wrap_err_with(|| {
format!(
"failed to resolve configured games directory {}",
games_folder.display()
)
})?;
let metadata = std::fs::metadata(&canonical)?;
if !metadata.is_dir() {
eyre::bail!(
"configured games directory is not a directory: {}",
canonical.display()
);
}
Ok(canonical)
}
fn validate_game_root(game_root: &Path) -> eyre::Result<()> {
let Some(metadata) = symlink_metadata_if_exists(game_root)? else {
return Ok(());
};
if is_link_or_reparse(&metadata) {
eyre::bail!(
"game root must not be a symlink or reparse point: {}",
game_root.display()
);
}
if !metadata.is_dir() {
eyre::bail!("game root is not a directory: {}", game_root.display());
}
Ok(())
}
fn validate_canonical_path(path: &str) -> eyre::Result<(String, Vec<&str>)> {
if path.is_empty() {
eyre::bail!("download path cannot be empty");
}
if path.starts_with('/') || path.ends_with('/') {
eyre::bail!("download path is not canonical: {path}");
}
if path.contains('\0') {
eyre::bail!("download path contains a NUL byte");
}
if path.len() > MAX_DOWNLOAD_RELATIVE_PATH_BYTES {
eyre::bail!("download path exceeds the {MAX_DOWNLOAD_RELATIVE_PATH_BYTES}-byte limit");
}
let components = path.split('/').collect::<Vec<_>>();
let mut aliases = Vec::with_capacity(components.len());
for component in &components {
validate_component(component)?;
aliases.push(windows_alias_component(component)?);
}
Ok((aliases.join("/"), components))
}
fn validate_component(component: &str) -> eyre::Result<()> {
if component.is_empty() || matches!(component, "." | "..") {
eyre::bail!("download path contains a non-canonical component: {component:?}");
}
if component.ends_with([' ', '.']) {
eyre::bail!("download path component has a trailing dot or space: {component}");
}
if component.len() > MAX_DOWNLOAD_COMPONENT_BYTES {
eyre::bail!(
"download path component exceeds the {MAX_DOWNLOAD_COMPONENT_BYTES}-byte limit"
);
}
if component.chars().any(|character| {
character <= '\u{1f}' || matches!(character, '<' | '>' | ':' | '"' | '|' | '?' | '*')
}) {
eyre::bail!("download path component is not portable: {component}");
}
let device_stem = component.split('.').next().unwrap_or_default();
if is_windows_device_name(device_stem) {
eyre::bail!("download path uses a Windows device name: {component}");
}
if looks_like_dos_short_name(component) {
eyre::bail!("download path resembles a Windows short-name alias: {component}");
}
Ok(())
}
fn windows_alias_component(component: &str) -> eyre::Result<String> {
validate_component(component)?;
Ok(portable_name_key(component))
}
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 && matches!(number.as_bytes()[0], b'1'..=b'9'))
|| matches!(number, "¹" | "²" | "³")
})
}
fn looks_like_dos_short_name(component: &str) -> bool {
let stem = component.split('.').next().unwrap_or_default();
stem.rsplit_once('~').is_some_and(|(prefix, suffix)| {
!prefix.is_empty()
&& !suffix.is_empty()
&& suffix.len() <= 6
&& suffix.bytes().all(|byte| byte.is_ascii_digit())
})
}
fn validate_entry_shape(
shapes: &mut BTreeMap<String, EntryShape>,
alias_path: &str,
is_dir: bool,
display_path: &str,
) -> eyre::Result<()> {
let shape = if is_dir {
EntryShape::Directory
} else {
EntryShape::File
};
if shapes.insert(alias_path.to_owned(), shape).is_some() {
eyre::bail!("duplicate or platform-alias download path: {display_path}");
}
let mut parent = alias_path;
while let Some((prefix, _)) = parent.rsplit_once('/') {
if shapes.get(prefix) == Some(&EntryShape::File) {
eyre::bail!("download path descends through a file: {display_path}");
}
parent = prefix;
}
if shape == EntryShape::File {
let descendant_prefix = format!("{alias_path}/");
if shapes
.range(descendant_prefix.clone()..)
.next()
.is_some_and(|(candidate, _)| candidate.starts_with(&descendant_prefix))
{
eyre::bail!("download file conflicts with a described child: {display_path}");
}
}
Ok(())
}
fn validate_existing_destination(
game_root: &Path,
components: &[&str],
is_dir: bool,
display_path: &str,
) -> eyre::Result<()> {
let mut destination = game_root.to_path_buf();
for component in components {
destination.push(component);
}
if platform_path_units(&destination) > MAX_DOWNLOAD_DESTINATION_UNITS {
eyre::bail!(
"download destination exceeds the {MAX_DOWNLOAD_DESTINATION_UNITS}-unit limit: {display_path}"
);
}
destination = game_root.to_path_buf();
for (index, component) in components.iter().enumerate() {
destination.push(component);
let Some(metadata) = symlink_metadata_if_exists(&destination)? else {
continue;
};
if is_link_or_reparse(&metadata) {
eyre::bail!("download destination contains a symlink or reparse point: {display_path}");
}
let is_final = index + 1 == components.len();
if !is_final && !metadata.is_dir() {
eyre::bail!("download destination descends through a file: {display_path}");
}
if is_final && ((is_dir && !metadata.is_dir()) || (!is_dir && !metadata.is_file())) {
eyre::bail!("download destination has the wrong filesystem type: {display_path}");
}
}
Ok(())
}
#[cfg(unix)]
fn platform_path_units(path: &Path) -> usize {
use std::os::unix::ffi::OsStrExt;
path.as_os_str().as_bytes().len()
}
#[cfg(windows)]
fn platform_path_units(path: &Path) -> usize {
use std::os::windows::ffi::OsStrExt;
path.as_os_str().encode_wide().count()
}
#[cfg(not(any(unix, windows)))]
fn platform_path_units(path: &Path) -> usize {
path.as_os_str().to_string_lossy().len()
}
fn symlink_metadata_if_exists(path: &Path) -> eyre::Result<Option<Metadata>> {
match std::fs::symlink_metadata(path) {
Ok(metadata) => Ok(Some(metadata)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
fn is_link_or_reparse(metadata: &Metadata) -> bool {
metadata.file_type().is_symlink() || is_windows_reparse_point(metadata)
}
#[cfg(windows)]
fn is_windows_reparse_point(metadata: &Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
const fn is_windows_reparse_point(_metadata: &Metadata) -> bool {
false
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use crate::test_support::TempDir;
#[derive(Debug, PartialEq, Eq)]
enum TreeEntry {
Directory,
File(Vec<u8>),
Symlink(PathBuf),
Other,
}
fn catalog() -> GameCatalog {
GameCatalog::from_ids(["game".to_owned()])
}
fn file(path: &str, size: u64) -> GameFileDescription {
GameFileDescription {
game_id: "game".to_owned(),
relative_path: path.to_owned(),
is_dir: false,
size,
}
}
fn directory(path: &str) -> GameFileDescription {
GameFileDescription {
game_id: "game".to_owned(),
relative_path: path.to_owned(),
is_dir: true,
size: 0,
}
}
fn valid_descriptions() -> Vec<GameFileDescription> {
vec![
directory("game"),
file("game/archive.eti", 10),
file("game/version.ini", 8),
]
}
fn validate(
temp: &TempDir,
descriptions: Vec<GameFileDescription>,
) -> eyre::Result<ValidatedDownloadManifest> {
ValidatedDownloadManifest::from_protocol_v7(temp.path(), "game", descriptions, &catalog())
}
fn snapshot_tree(root: &Path) -> BTreeMap<PathBuf, TreeEntry> {
walkdir::WalkDir::new(root)
.follow_links(false)
.into_iter()
.map(|entry| entry.expect("test tree should be readable"))
.filter(|entry| entry.path() != root)
.map(|entry| {
let relative = entry
.path()
.strip_prefix(root)
.expect("entry should be below root")
.to_path_buf();
let file_type = entry.file_type();
let value = if file_type.is_dir() {
TreeEntry::Directory
} else if file_type.is_file() {
TreeEntry::File(
std::fs::read(entry.path()).expect("test file should be readable"),
)
} else if file_type.is_symlink() {
TreeEntry::Symlink(
std::fs::read_link(entry.path()).expect("test link should be readable"),
)
} else {
TreeEntry::Other
};
(relative, value)
})
.collect()
}
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent should be created");
}
std::fs::write(path, bytes).expect("test file should be written");
}
fn assert_rejected_without_mutation(descriptions: Vec<GameFileDescription>) {
let temp = TempDir::new("lanspread-manifest-unchanged");
write_file(&temp.game_root().join("archive.eti"), b"original");
write_file(&temp.game_root().join("version.ini"), b"20250101");
write_file(&temp.game_root().join("local/save.dat"), b"save");
write_file(&temp.path().join("sibling/local/save.dat"), b"sibling");
let before = snapshot_tree(temp.path());
assert!(validate(&temp, descriptions).is_err());
assert_eq!(snapshot_tree(temp.path()), before);
}
#[test]
fn protocol_v7_adapter_strips_exact_game_prefix() {
let temp = TempDir::new("lanspread-manifest-valid");
let manifest = validate(&temp, valid_descriptions()).expect("manifest should validate");
let paths = manifest
.entries()
.iter()
.map(ValidatedDownloadEntry::canonical_path)
.collect::<Vec<_>>();
assert_eq!(paths, ["archive.eti", "version.ini"]);
let version_ini = manifest
.entries()
.iter()
.find(|entry| entry.is_version_ini())
.expect("version.ini should exist");
assert_eq!(version_ini.protocol_path(), "game/version.ini");
}
#[test]
fn rejects_unknown_catalog_game() {
let temp = TempDir::new("lanspread-manifest-unknown");
let error = ValidatedDownloadManifest::from_protocol_v7(
temp.path(),
"unknown",
Vec::new(),
&catalog(),
)
.expect_err("unknown game should fail");
assert!(error.to_string().contains("unknown catalog game"));
}
#[test]
fn rejects_missing_different_and_doubled_game_prefixes() {
let temp = TempDir::new("lanspread-manifest-prefix");
for path in ["version.ini", "other/version.ini", "game/game/version.ini"] {
let descriptions = vec![file("game/archive.eti", 10), file(path, 8)];
assert!(validate(&temp, descriptions).is_err(), "accepted {path}");
}
}
#[test]
fn rejects_cross_game_description_identity() {
let temp = TempDir::new("lanspread-manifest-cross-game");
let mut descriptions = valid_descriptions();
descriptions[1].game_id = "other".to_owned();
assert!(validate(&temp, descriptions).is_err());
}
#[test]
fn rejects_noncanonical_and_nonportable_paths() {
let temp = TempDir::new("lanspread-manifest-noncanonical");
for path in [
"game/a\\b.eti",
"game//archive.eti",
"game/./archive.eti",
"game/../archive.eti",
"game/archive.eti/",
"game/C:/archive.eti",
"game/archive?.eti",
"game/archive.eti\0suffix",
] {
let descriptions = vec![file(path, 10), file("game/version.ini", 8)];
assert!(validate(&temp, descriptions).is_err(), "accepted {path:?}");
}
}
#[test]
fn rejects_portability_aliases_and_path_length_overflows() {
let temp = TempDir::new("lanspread-manifest-portability");
for path in [
"game/.ſync/state",
"game/COM¹.txt",
"game/LPT³.log",
"game/LOCAL~1/save.dat",
] {
let descriptions = vec![file(path, 1), file("game/version.ini", 8)];
assert!(validate(&temp, descriptions).is_err(), "accepted {path}");
}
let long_component = format!("game/{}", "a".repeat(MAX_DOWNLOAD_COMPONENT_BYTES + 1));
assert!(
validate(
&temp,
vec![file(&long_component, 1), file("game/version.ini", 8)]
)
.is_err()
);
let long_relative = std::iter::repeat_n("a".repeat(200), 5)
.collect::<Vec<_>>()
.join("/");
let long_path = format!("game/{long_relative}");
assert!(
validate(
&temp,
vec![file(&long_path, 1), file("game/version.ini", 8)]
)
.is_err()
);
}
#[test]
fn rejects_install_and_recovery_owned_roots() {
let temp = TempDir::new("lanspread-manifest-reserved");
for component in [
"local",
"LOCAL",
".local.installing",
".local.backup",
".sync",
".lanspread",
".lanspread.json",
".lanspread.json.tmp",
".lanspread_owned",
".softlan_first_start_done",
".softlan_game_installed",
".version.ini.tmp",
".version.ini.discarded",
"install_intent.json",
"install_intent.json.tmp",
] {
let path = format!("game/{component}/payload.bin");
let descriptions = vec![file(&path, 10), file("game/version.ini", 8)];
assert!(
validate(&temp, descriptions).is_err(),
"accepted protected path {path}"
);
}
}
#[test]
fn rejects_duplicates_platform_aliases_and_shape_conflicts() {
let temp = TempDir::new("lanspread-manifest-alias");
let cases = [
vec![file("game/A.eti", 1), file("game/a.eti", 1)],
vec![file("game/archive.eti", 1), file("game/archive.eti", 1)],
vec![file("game/con.txt", 1)],
vec![file("game/archive.eti.", 1)],
vec![file("game/archive.eti ", 1)],
vec![file("game/dir", 1), file("game/dir/child", 1)],
vec![file("game/dir/child", 1), file("game/dir", 1)],
vec![directory("game/dir"), file("game/dir", 1)],
vec![directory("game"), directory("game")],
];
for mut descriptions in cases {
descriptions.push(file("game/version.ini", 8));
assert!(validate(&temp, descriptions).is_err());
}
}
#[test]
fn raw_peer_validation_rejects_duplicates_before_consensus() {
let descriptions = vec![
file("game/version.ini", 8),
file("game/archive.eti", 1),
file("game/archive.eti", 1),
];
assert!(validate_protocol_v7_descriptions("game", descriptions).is_err());
}
#[test]
fn requires_exactly_one_regular_root_version_ini() {
let temp = TempDir::new("lanspread-manifest-version");
assert!(validate(&temp, vec![file("game/archive.eti", 1)]).is_err());
assert!(
validate(
&temp,
vec![file("game/version.ini", 8), file("game/version.ini", 8)]
)
.is_err()
);
assert!(validate(&temp, vec![directory("game/version.ini")]).is_err());
}
#[test]
fn rejects_nonzero_directory_and_size_limits() {
let temp = TempDir::new("lanspread-manifest-limits");
let mut bad_dir = directory("game/data");
bad_dir.size = 1;
assert!(validate(&temp, vec![bad_dir, file("game/version.ini", 8)]).is_err());
assert!(
validate(
&temp,
vec![
file("game/archive.eti", MAX_DOWNLOAD_FILE_BYTES + 1),
file("game/version.ini", 8),
]
)
.is_err()
);
assert!(
validate(
&temp,
vec![
file("game/version.ini", MAX_VERSION_INI_BYTES + 1),
file("game/archive.eti", 1),
]
)
.is_err()
);
let descriptions = vec![file("game/version.ini", 8); MAX_DOWNLOAD_MANIFEST_ENTRIES + 1];
assert!(validate(&temp, descriptions).is_err());
}
#[test]
fn hostile_late_descriptor_leaves_filesystem_unchanged() {
let temp = TempDir::new("lanspread-manifest-zero-mutation");
let game_root = temp.game_root();
std::fs::create_dir_all(&game_root).expect("game root should be created");
std::fs::write(game_root.join("archive.eti"), b"original")
.expect("existing archive should be written");
std::fs::write(game_root.join("version.ini"), b"20250101")
.expect("existing version should be written");
let descriptions = vec![
file("game/archive.eti", 1),
file("game/version.ini", 8),
file("game/local/save.dat", 1),
];
assert!(validate(&temp, descriptions).is_err());
assert_eq!(
std::fs::read(game_root.join("archive.eti")).expect("archive should remain"),
b"original"
);
assert_eq!(
std::fs::read(game_root.join("version.ini")).expect("version should remain"),
b"20250101"
);
assert_eq!(
std::fs::read_dir(&game_root)
.expect("game root should remain readable")
.count(),
2
);
}
#[test]
fn rejection_categories_leave_the_complete_tree_unchanged() {
let cases = [
vec![file("game/version.ini", 8), file("other/local/save.dat", 1)],
vec![file("game/version.ini", 8), file("game/local/save.dat", 1)],
vec![file("game/version.ini", 8), file("game/.sync/state", 1)],
vec![file("game/version.ini", 8), file("game/../escape", 1)],
vec![
file("game/version.ini", 8),
file("game/A.eti", 1),
file("game/a.eti", 1),
],
vec![
file("game/version.ini", 8),
file("game/dir", 1),
file("game/dir/child", 1),
],
vec![file("game/archive.eti", 1)],
vec![directory("game/version.ini")],
vec![
file("game/version.ini", 8),
file("game/archive.eti", MAX_DOWNLOAD_FILE_BYTES + 1),
],
vec![
directory("game"),
directory("game"),
file("game/version.ini", 8),
],
];
for descriptions in cases {
assert_rejected_without_mutation(descriptions);
}
assert_rejected_without_mutation(vec![
file("game/version.ini", 8);
MAX_DOWNLOAD_MANIFEST_ENTRIES + 1
]);
}
#[cfg(unix)]
#[test]
fn rejects_symlink_game_roots_and_destination_components() {
use std::os::unix::fs::symlink;
let root_link = TempDir::new("lanspread-manifest-root-link");
let outside = TempDir::new("lanspread-manifest-outside");
symlink(outside.path(), root_link.path().join("game"))
.expect("game root symlink should be created");
assert!(validate(&root_link, valid_descriptions()).is_err());
let child_link = TempDir::new("lanspread-manifest-child-link");
std::fs::create_dir_all(child_link.game_root()).expect("game root should be created");
symlink(outside.path(), child_link.game_root().join("payload"))
.expect("child symlink should be created");
let descriptions = vec![
file("game/payload/file.bin", 1),
file("game/version.ini", 8),
];
assert!(validate(&child_link, descriptions).is_err());
}
}