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:
2026-08-09 17:51:11 +02:00
parent 9268de2371
commit a6ed60a538
12 changed files with 1376 additions and 137 deletions
@@ -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());
}
}
+3 -1
View File
@@ -1,5 +1,6 @@
//! Download pipeline for game files from peers.
mod manifest;
mod orchestrator;
mod planning;
mod progress;
@@ -8,4 +9,5 @@ mod storage;
mod transport;
mod version_ini;
pub use orchestrator::download_game_files;
pub(crate) use manifest::{ValidatedDownloadManifest, validate_protocol_v7_descriptions};
pub(crate) use orchestrator::download_game_files;
@@ -1,15 +1,11 @@
use std::{
collections::HashMap,
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
};
use std::{collections::HashMap, net::SocketAddr, path::Path, sync::Arc};
use lanspread_db::db::GameFileDescription;
use tokio::sync::mpsc::UnboundedSender;
use tokio_util::sync::CancellationToken;
use super::{
manifest::ValidatedDownloadManifest,
planning::{ChunkDownloadResult, DownloadChunk, build_peer_plans, extract_version_descriptor},
progress::{DownloadProgressTracker, sample_download_progress},
retry::{RetryContext, retry_failed_chunks},
@@ -26,15 +22,15 @@ use crate::{PeerEvent, config::MAX_RETRY_COUNT};
/// Downloads all game files from available peers.
#[allow(clippy::too_many_lines)]
pub async fn download_game_files(
game_id: &str,
game_file_descs: Vec<GameFileDescription>,
games_folder: PathBuf,
pub(crate) async fn download_game_files(
manifest: ValidatedDownloadManifest,
peers: Vec<SocketAddr>,
file_peer_map: HashMap<String, Vec<SocketAddr>>,
tx_notify_ui: UnboundedSender<PeerEvent>,
cancel_token: CancellationToken,
) -> eyre::Result<()> {
let game_id = manifest.game_id().to_owned();
let games_folder = manifest.games_folder().to_path_buf();
if peers.is_empty() {
eyre::bail!("no peers available for game {game_id}");
}
@@ -43,40 +39,41 @@ pub async fn download_game_files(
eyre::bail!("download cancelled for game {game_id}");
}
let (version_desc, transfer_descs) = extract_version_descriptor(game_id, game_file_descs)?;
let game_file_descs = manifest.protocol_descriptions();
let (version_desc, transfer_descs) = extract_version_descriptor(&game_id, game_file_descs)?;
let version_buffer = match VersionIniBuffer::new(&version_desc) {
Ok(buffer) => Arc::new(buffer),
Err(err) => return Err(err),
};
let game_root = games_folder.join(game_id);
let game_root = manifest.game_root().to_path_buf();
begin_version_ini_transaction(&game_root).await?;
if cancel_token.is_cancelled() {
rollback_version_ini_transaction(&game_root).await;
discard_cancelled_download_best_effort(&games_folder, game_id).await;
discard_cancelled_download_best_effort(&games_folder, &game_id).await;
eyre::bail!("download cancelled for game {game_id}");
}
if let Err(err) = prepare_game_storage(&games_folder, &transfer_descs).await {
if let Err(err) = prepare_game_storage(&manifest).await {
rollback_version_ini_transaction(&game_root).await;
if cancel_token.is_cancelled() {
discard_cancelled_download_best_effort(&games_folder, game_id).await;
discard_cancelled_download_best_effort(&games_folder, &game_id).await;
eyre::bail!("download cancelled for game {game_id}");
}
return Err(err);
}
if cancel_token.is_cancelled() {
rollback_version_ini_transaction(&game_root).await;
discard_cancelled_download_best_effort(&games_folder, game_id).await;
discard_cancelled_download_best_effort(&games_folder, &game_id).await;
eyre::bail!("download cancelled for game {game_id}");
}
tx_notify_ui.send(PeerEvent::DownloadGameFilesBegin {
id: game_id.to_string(),
id: game_id.clone(),
})?;
let progress_tracker = DownloadProgressTracker::new(total_download_bytes(&transfer_descs));
let transfer_ctx = TransferContext {
game_id,
game_id: &game_id,
games_folder: &games_folder,
peers: &peers,
file_peer_map: &file_peer_map,
@@ -86,7 +83,7 @@ pub async fn download_game_files(
progress_tracker: progress_tracker.clone(),
};
let transfer_result = sample_download_progress(
game_id,
&game_id,
progress_tracker,
tx_notify_ui.clone(),
download_transfer_chunks(&transfer_ctx, &transfer_descs),
@@ -96,14 +93,14 @@ pub async fn download_game_files(
if let Err(err) = transfer_result {
rollback_version_ini_transaction(&game_root).await;
if cancel_token.is_cancelled() {
discard_cancelled_download_best_effort(&games_folder, game_id).await;
discard_cancelled_download_best_effort(&games_folder, &game_id).await;
}
return Err(err);
}
if cancel_token.is_cancelled() {
rollback_version_ini_transaction(&game_root).await;
discard_cancelled_download_best_effort(&games_folder, game_id).await;
discard_cancelled_download_best_effort(&games_folder, &game_id).await;
eyre::bail!("download cancelled for game {game_id}");
}
+18 -18
View File
@@ -1,25 +1,18 @@
use std::{io::ErrorKind, path::Path};
use lanspread_db::db::GameFileDescription;
use tokio::fs::OpenOptions;
use crate::{local_games::is_local_dir_name, path_validation::validate_game_file_path};
use super::manifest::ValidatedDownloadManifest;
use crate::local_games::is_local_dir_name;
const SYNC_DIR: &str = ".sync";
/// Prepares storage for game files by creating directories and pre-allocating files.
pub(super) async fn prepare_game_storage(
games_folder: &Path,
file_descs: &[GameFileDescription],
) -> eyre::Result<()> {
for desc in file_descs {
if desc.is_version_ini() {
continue;
}
pub(super) async fn prepare_game_storage(manifest: &ValidatedDownloadManifest) -> eyre::Result<()> {
for entry in manifest.transfer_entries() {
let validated_path = manifest.game_root().join(entry.canonical_path());
let validated_path = validate_game_file_path(games_folder, &desc.relative_path)?;
if desc.is_dir {
if entry.is_dir() {
tokio::fs::create_dir_all(&validated_path).await?;
} else {
if let Some(parent) = validated_path.parent() {
@@ -33,18 +26,18 @@ pub(super) async fn prepare_game_storage(
.open(&validated_path)
.await?;
let size = desc.size;
let size = entry.size();
if let Err(e) = file.set_len(size).await {
log::warn!(
"Failed to pre-allocate file {} (size: {}): {}",
desc.relative_path,
entry.canonical_path(),
size,
e
);
} else {
log::debug!(
"Pre-allocated file {} with {} bytes",
desc.relative_path,
entry.canonical_path(),
size
);
}
@@ -148,7 +141,7 @@ async fn symlink_metadata_if_exists(path: &Path) -> eyre::Result<Option<std::fs:
#[cfg(test)]
mod tests {
use lanspread_db::db::GameFileDescription;
use lanspread_db::db::{GameCatalog, GameFileDescription};
use super::*;
use crate::test_support::TempDir;
@@ -169,8 +162,15 @@ mod tests {
is_dir: false,
size: 8,
}];
let manifest = ValidatedDownloadManifest::from_protocol_v7(
temp.path(),
"game",
descs,
&GameCatalog::from_ids(["game".to_owned()]),
)
.expect("manifest should validate");
prepare_game_storage(temp.path(), &descs)
prepare_game_storage(&manifest)
.await
.expect("storage preparation should succeed");
+80 -34
View File
@@ -37,6 +37,48 @@ fn ensure_download_not_cancelled(
Ok(())
}
#[derive(Clone, Copy, Debug)]
struct ReceiveBudget {
expected: u64,
received: u64,
}
impl ReceiveBudget {
const fn new(expected: u64) -> Self {
Self {
expected,
received: 0,
}
}
fn accept(&mut self, byte_count: usize) -> eyre::Result<()> {
let byte_count = u64::try_from(byte_count)?;
self.received = self
.received
.checked_add(byte_count)
.ok_or_else(|| eyre::eyre!("received chunk byte count overflow"))?;
if self.received > self.expected {
eyre::bail!(
"peer sent too many chunk bytes: expected {}, received at least {}",
self.expected,
self.received
);
}
Ok(())
}
fn finish(self) -> eyre::Result<()> {
if self.received != self.expected {
eyre::bail!(
"incomplete chunk download: expected {} bytes, received {}",
self.expected,
self.received
);
}
Ok(())
}
}
async fn open_chunk_stream(
conn: &mut Connection,
game_id: &str,
@@ -84,41 +126,21 @@ async fn receive_chunk(
.open(&validated_path)
.await?;
if chunk.length == 0 && chunk.offset == 0 {
// fallback-to-whole-file path replaces any existing partial data
// A manifest-declared empty file replaces any existing partial data.
file.set_len(0).await?;
}
file.seek(std::io::SeekFrom::Start(chunk.offset)).await?;
let mut remaining = chunk.length;
let mut received_bytes = 0u64;
let mut receive_budget = ReceiveBudget::new(chunk.length);
let mut progress =
progress_tracker.track_chunk(peer_addr, &chunk.relative_path, chunk.offset, chunk.length);
while let Some(bytes) = rx.receive().await? {
receive_budget.accept(bytes.len())?;
file.write_all(&bytes).await?;
progress.record_bytes(bytes.len());
let byte_count = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
received_bytes = received_bytes.saturating_add(byte_count);
if remaining == 0 {
continue;
}
remaining = remaining.saturating_sub(byte_count);
if remaining == 0 {
break;
}
}
// Verify we received the expected amount of data
if chunk.length > 0 && received_bytes != chunk.length {
eyre::bail!(
"Incomplete chunk download: expected {} bytes, received {} bytes for file {} at offset {}",
chunk.length,
received_bytes,
chunk.relative_path,
chunk.offset
);
}
receive_budget.finish()?;
file.flush().await?;
@@ -159,22 +181,16 @@ async fn download_version_ini_chunk(
buffer: &VersionIniBuffer,
progress_tracker: Arc<DownloadProgressTracker>,
) -> eyre::Result<()> {
let mut received = Vec::new();
let mut received = Vec::with_capacity(usize::try_from(chunk.length)?);
let mut receive_budget = ReceiveBudget::new(chunk.length);
let mut progress =
progress_tracker.track_chunk(peer_addr, &chunk.relative_path, chunk.offset, chunk.length);
while let Some(bytes) = rx.receive().await? {
receive_budget.accept(bytes.len())?;
progress.record_bytes(bytes.len());
received.extend_from_slice(&bytes);
}
if chunk.length > 0 && u64::try_from(received.len())? != chunk.length {
eyre::bail!(
"Incomplete version.ini chunk download: expected {} bytes, received {} bytes at offset {}",
chunk.length,
received.len(),
chunk.offset
);
}
receive_budget.finish()?;
buffer.write_at(chunk.offset, &received).await
}
@@ -349,3 +365,33 @@ pub(super) async fn download_from_peer(
Ok(results)
}
#[cfg(test)]
mod tests {
use super::ReceiveBudget;
#[test]
fn receive_budget_accepts_exactly_the_requested_bytes() {
let mut budget = ReceiveBudget::new(5);
budget.accept(2).expect("first frame should fit");
budget.accept(3).expect("second frame should fit");
budget.finish().expect("exact byte count should finish");
}
#[test]
fn receive_budget_rejects_short_extra_and_nonempty_zero_length_streams() {
let mut short = ReceiveBudget::new(5);
short.accept(4).expect("partial frame should fit");
assert!(short.finish().is_err());
let mut extra = ReceiveBudget::new(5);
extra.accept(5).expect("exact frame should fit");
assert!(extra.accept(1).is_err());
let mut zero = ReceiveBudget::new(0);
assert!(zero.accept(1).is_err());
ReceiveBudget::new(0)
.finish()
.expect("empty zero-length stream should finish");
}
}
+70 -25
View File
@@ -17,7 +17,7 @@ use crate::{
InstallOperation,
PeerEvent,
context::{Ctx, OperationGuard, OperationKind},
download::download_game_files,
download::{ValidatedDownloadManifest, download_game_files, validate_protocol_v7_descriptions},
events,
install,
local_games::{
@@ -217,7 +217,6 @@ pub async fn handle_download_game_files_command(
ctx: &Ctx,
tx_notify_ui: &UnboundedSender<PeerEvent>,
id: String,
file_descriptions: Vec<GameFileDescription>,
install_after_download: bool,
) {
log::info!("Got PeerCommand::DownloadGameFiles");
@@ -232,13 +231,43 @@ pub async fn handle_download_game_files_command(
let games_folder = { ctx.game_dir.read().await.clone() };
let expected_version = catalog_expected_version(ctx, &id).await;
// Use majority validation to get trusted file descriptions and peer whitelist
let raw_peer_manifests = ctx
.peer_game_db
.read()
.await
.expected_version_game_files_for(&id, expected_version.as_deref());
let validation_game_id = id.clone();
let raw_validation = tokio::task::spawn_blocking(move || {
let mut valid = Vec::new();
let mut rejected = Vec::new();
for (peer_addr, descriptions) in raw_peer_manifests {
match validate_protocol_v7_descriptions(&validation_game_id, descriptions) {
Ok(descriptions) => valid.push((peer_addr, descriptions)),
Err(error) => rejected.push((peer_addr, error.to_string())),
}
}
(valid, rejected)
})
.await;
let (peer_manifests, rejected_manifests) = match raw_validation {
Ok(result) => result,
Err(error) => {
log::error!("Peer manifest validation task failed for {id}: {error}");
send_download_failed(tx_notify_ui, &id);
return;
}
};
for (peer_addr, error) in rejected_manifests {
log::warn!("Ignoring invalid download manifest from {peer_addr} for {id}: {error}");
}
// Use only complete, individually valid peer manifests for size consensus.
let (validated_descriptions, peer_whitelist, file_peer_map) = {
match ctx
.peer_game_db
.read()
.await
.validate_file_sizes_majority(&id, expected_version.as_deref())
.validate_file_sizes_majority_from(&id, &peer_manifests)
{
Ok((files, peers, file_peer_map)) => {
log::info!(
@@ -260,24 +289,6 @@ pub async fn handle_download_game_files_command(
}
};
let resolved_descriptions = if file_descriptions.is_empty() {
validated_descriptions
} else {
// If user provided specific descriptions, still validate them against majority
// but keep user's selection (they might want specific files)
file_descriptions
};
if resolved_descriptions.is_empty() {
log::error!(
"No validated file descriptions available to download game {id}; request metadata first"
);
if let Err(send_err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id }) {
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
}
return;
}
let local_dl_available = {
let active_operations = ctx.active_operations.read().await;
let catalog = ctx.catalog.read().await;
@@ -310,6 +321,42 @@ pub async fn handle_download_game_files_command(
return;
}
if validated_descriptions.is_empty() {
log::error!(
"No validated file descriptions available to download game {id}; request metadata first"
);
if let Err(send_err) = tx_notify_ui.send(PeerEvent::DownloadGameFilesFailed { id }) {
log::error!("Failed to send DownloadGameFilesFailed event: {send_err}");
}
return;
}
let catalog = ctx.catalog.read().await.clone();
let manifest_games_folder = games_folder.clone();
let manifest_game_id = id.clone();
let manifest = tokio::task::spawn_blocking(move || {
ValidatedDownloadManifest::from_protocol_v7(
&manifest_games_folder,
&manifest_game_id,
validated_descriptions,
&catalog,
)
})
.await;
let manifest = match manifest {
Ok(Ok(manifest)) => manifest,
Ok(Err(error)) => {
log::error!("Rejected download manifest for {id}: {error}");
send_download_failed(tx_notify_ui, &id);
return;
}
Err(error) => {
log::error!("Download manifest validation task failed for {id}: {error}");
send_download_failed(tx_notify_ui, &id);
return;
}
};
match begin_operation(ctx, tx_notify_ui, &id, OperationKind::Downloading).await {
BeginOperationResult::Started => {}
BeginOperationResult::AlreadyActive => {
@@ -344,9 +391,7 @@ pub async fn handle_download_game_files_command(
);
let result = download_game_files(
&download_id,
resolved_descriptions,
games_folder,
manifest,
peer_whitelist,
file_peer_map,
tx_notify_ui_clone.clone(),
+5 -20
View File
@@ -238,14 +238,10 @@ pub enum PeerCommand {
/// Request the latest peer-advertised file details for an update.
FetchLatestFromPeers { id: String },
/// Download game files.
DownloadGameFiles {
id: String,
file_descriptions: Vec<GameFileDescription>,
},
DownloadGameFiles { id: String },
/// Download game files with an explicit install policy.
DownloadGameFilesWithOptions {
id: String,
file_descriptions: Vec<GameFileDescription>,
install_after_download: bool,
},
/// Stream archive-expanded bytes directly into `local/` without keeping root archives.
@@ -458,26 +454,15 @@ async fn handle_peer_commands(
handle_get_game_command(ctx, tx_notify_ui, id, GameDetailSource::LatestPeersOnly)
.await;
}
PeerCommand::DownloadGameFiles {
id,
file_descriptions,
} => {
handle_download_game_files_command(ctx, tx_notify_ui, id, file_descriptions, true)
.await;
PeerCommand::DownloadGameFiles { id } => {
handle_download_game_files_command(ctx, tx_notify_ui, id, true).await;
}
PeerCommand::DownloadGameFilesWithOptions {
id,
file_descriptions,
install_after_download,
} => {
handle_download_game_files_command(
ctx,
tx_notify_ui,
id,
file_descriptions,
install_after_download,
)
.await;
handle_download_game_files_command(ctx, tx_notify_ui, id, install_after_download)
.await;
}
PeerCommand::StreamInstallGame { id } => {
handlers::handle_stream_install_game_command(ctx, tx_notify_ui, id).await;
+34 -2
View File
@@ -4,7 +4,7 @@ use std::{
collections::{HashMap, HashSet},
hash::{Hash, Hasher},
io::ErrorKind,
path::{Path, PathBuf},
path::{Component, Path, PathBuf},
sync::LazyLock,
time::{SystemTime, UNIX_EPOCH},
};
@@ -353,6 +353,29 @@ fn should_skip_root_entry(entry: &walkdir::DirEntry) -> bool {
false
}
fn canonical_protocol_path(path: &Path) -> eyre::Result<String> {
let mut components = Vec::new();
for component in path.components() {
let Component::Normal(component) = component else {
eyre::bail!("local game path is not canonical: {}", path.display());
};
let component = component
.to_str()
.ok_or_else(|| eyre::eyre!("local game path is not valid UTF-8: {}", path.display()))?;
if component.contains('\\') {
eyre::bail!(
"local game path cannot be represented portably: {}",
path.display()
);
}
components.push(component);
}
if components.is_empty() {
eyre::bail!("local game path cannot be empty");
}
Ok(components.join("/"))
}
async fn scan_game_descriptions(
game_id: &str,
game_dir: &Path,
@@ -375,7 +398,7 @@ async fn scan_game_descriptions(
.filter_map(std::result::Result::ok)
{
let relative_path = match entry.path().strip_prefix(base_dir) {
Ok(path) => path.to_string_lossy().to_string(),
Ok(path) => canonical_protocol_path(path).map_err(PeerError::Other)?,
Err(e) => {
log::error!(
"Failed to get relative path for {}: {}",
@@ -730,6 +753,15 @@ mod tests {
std::fs::write(path, bytes).expect("file should be written");
}
#[test]
fn protocol_paths_always_use_forward_slashes() {
let native = PathBuf::from("game").join("nested").join("archive.eti");
assert_eq!(
canonical_protocol_path(&native).expect("native path should convert"),
"game/nested/archive.eti"
);
}
fn test_library_index(revision: u64, id: &str, manifest_hash: u64) -> LibraryIndex {
LibraryIndex {
revision,
+60 -2
View File
@@ -650,11 +650,20 @@ impl PeerGameDB {
expected_version: Option<&str>,
) -> eyre::Result<MajorityValidationResult> {
let game_files = self.expected_version_game_files_for(game_id, expected_version);
self.validate_file_sizes_majority_from(game_id, &game_files)
}
/// Validates file-size consensus over caller-sanitized complete peer manifests.
pub(crate) fn validate_file_sizes_majority_from(
&self,
game_id: &str,
game_files: &[(SocketAddr, Vec<GameFileDescription>)],
) -> eyre::Result<MajorityValidationResult> {
if game_files.is_empty() {
return Ok((Vec::new(), Vec::new(), HashMap::new()));
}
let (file_size_map, _peer_files) = collect_file_sizes(&game_files);
let (file_size_map, _peer_files) = collect_file_sizes(game_files);
let (validated_files, peer_scores, file_peer_map) =
self.validate_each_file_consensus(game_id, file_size_map)?;
let peer_whitelist = create_peer_whitelist(peer_scores);
@@ -835,7 +844,7 @@ fn collect_file_sizes(
for (peer_addr, files) in game_files {
let mut peer_file_sizes = HashMap::new();
for file in files {
if !file.is_dir {
if !file.is_dir && !peer_file_sizes.contains_key(&file.relative_path) {
let size = file.size;
file_size_map
.entry(file.relative_path.clone())
@@ -1160,4 +1169,53 @@ mod tests {
assert_eq!(archive.size, 20);
assert_eq!(file_peer_map.get("game/archive.eti"), Some(&vec![new_addr]));
}
#[test]
fn duplicate_entries_from_one_peer_do_not_manufacture_consensus() {
let attacker = addr(12005);
let honest_a = addr(12006);
let honest_b = addr(12007);
let mut db = PeerGameDB::new();
for (peer_id, peer_addr) in [
("attacker", attacker),
("honest-a", honest_a),
("honest-b", honest_b),
] {
let peer_id = peer_id.to_owned();
db.upsert_peer(peer_id.clone(), peer_addr);
db.update_peer_games(
&peer_id,
vec![summary("game", "20250101", Availability::Ready)],
);
}
let mut attacker_files = vec![file_desc("game", "game/version.ini", 8)];
attacker_files
.extend(std::iter::repeat_with(|| file_desc("game", "game/archive.eti", 99)).take(10));
let honest_files = vec![
file_desc("game", "game/version.ini", 8),
file_desc("game", "game/archive.eti", 20),
];
db.update_peer_game_files(&"attacker".to_owned(), "game", attacker_files.clone());
db.update_peer_game_files(&"honest-a".to_owned(), "game", honest_files.clone());
db.update_peer_game_files(&"honest-b".to_owned(), "game", honest_files.clone());
let manifests = vec![
(attacker, attacker_files),
(honest_a, honest_files.clone()),
(honest_b, honest_files),
];
let (validated, _, file_peer_map) = db
.validate_file_sizes_majority_from("game", &manifests)
.expect("honest peers should determine consensus");
let archive = validated
.iter()
.find(|description| description.relative_path == "game/archive.eti")
.expect("archive should validate");
assert_eq!(archive.size, 20);
assert_eq!(
file_peer_map.get("game/archive.eti"),
Some(&vec![honest_a, honest_b])
);
}
}