feat(peer): validate manifests before download mutation

Why:
- Remote and UI-echoed file descriptions could reach transaction and storage
  code one entry at a time, so a hostile late path could mutate earlier files.
- Per-file consensus also accepted malformed peer lists and let duplicate rows
  inflate a source's vote.

What:
- Add a complete protocol-7 manifest adapter with catalog-root confinement,
  portable path and alias rules, reserved-path protection, shape and size caps,
  symlink/reparse inspection, and zero-mutation tests.
- Keep download selection in the peer core, validate every peer manifest before
  consensus, and pass only the validated manifest into storage/orchestration.
- Canonicalize locally advertised paths, cap exact chunk receives, and preserve
  the local-only install fast path.
- Record the chosen safety limits and follow-up ownership/catalog decisions.

Test Plan:
- just clippy
- just test
- just frontend-test
- just build
- just fmt (Rust/TOML/Prettier completed; rumdl reports 39 pre-existing issues)
- git diff --cached --check
This commit is contained in:
ddidderr committed 2026-08-09 17:51:11 +02:00
1 parent 9268de2371
commit a6ed60a538
12 files changed
+1376 -137

No files matched your search

@@ -0,0 +1,1011 @@
use std::{
collections::BTreeMap,
fs::Metadata,
path::{Path, PathBuf},
};
use eyre::WrapErr;
use lanspread_db::db::{GameCatalog, GameFileDescription};
/// 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;
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 {
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_protected_root_component(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_protected_root_component(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(component.to_uppercase())
}
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 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,
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());
}
}