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
+1 -2
View File
@@ -276,10 +276,9 @@ async fn handle_command(
} => {
ensure_catalog_game(shared, game_id).await?;
ensure_no_active_operation(shared, game_id).await?;
let files = game_files_for_download(sender, shared, game_id).await?;
game_files_for_download(sender, shared, game_id).await?;
sender.send(PeerCommand::DownloadGameFilesWithOptions {
id: game_id.clone(),
file_descriptions: files,
install_after_download: *install_after_download,
})?;
Ok(json!({"queued": true, "game_id": game_id, "install": install_after_download}))
@@ -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])
);
}
}
@@ -14,7 +14,7 @@ use std::{
use eyre::bail;
use lanspread_compat::eti::get_games;
use lanspread_db::db::{Availability, Game, GameCatalog, GameDB, GameFileDescription};
use lanspread_db::db::{Availability, Game, GameCatalog, GameDB};
use lanspread_peer::{
ActiveOperation,
ActiveOperationKind,
@@ -2249,9 +2249,9 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
PeerEvent::GotGameFiles {
id,
file_descriptions,
file_descriptions: _,
} => {
handle_got_game_files(app_handle, id, file_descriptions).await;
handle_got_game_files(app_handle, id).await;
}
PeerEvent::NoPeersHaveGame { id } => {
log::warn!("PeerEvent::NoPeersHaveGame received for {id}");
@@ -2382,20 +2382,13 @@ async fn handle_peer_event(app_handle: &AppHandle, event: PeerEvent) {
}
}
async fn handle_got_game_files(
app_handle: &AppHandle,
id: String,
file_descriptions: Vec<GameFileDescription>,
) {
async fn handle_got_game_files(app_handle: &AppHandle, id: String) {
log::info!("PeerEvent::GotGameFiles received");
let state = app_handle.state::<LanSpreadState>();
let peer_ctrl = state.peer_ctrl.read().await.clone();
if let Some(peer_ctrl) = peer_ctrl
&& let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles {
id,
file_descriptions,
})
&& let Err(e) = peer_ctrl.send(PeerCommand::DownloadGameFiles { id })
{
log::error!("Failed to continue queued game transfer: {e}");
}
@@ -0,0 +1,71 @@
# Peer authentication refactor decisions
This file records implementation choices that are not fully determined by
`PEER_AUTH_PLAN.md`, especially choices where every available option has a
meaningful downside.
## 2026-08-09 — Bound protocol-7 download descriptions generously
**TL;DR:** Until catalog manifests replace remote descriptions, accept at most
100,000 entries, 1 TiB per ordinary file, 64 KiB for the in-memory `version.ini`
sentinel, 255 bytes per path component, 900 bytes per relative path, and 16 TiB
in aggregate for one game. Resolved destinations are capped at 1,000 platform
path units.
The plan requires count, per-file, and aggregate limits but intentionally does
not prescribe values. These limits are far above the expected game and CLI
fixture sizes while bounding allocation, planning work, and arithmetic.
Alternatives:
- Lower product-sized limits would reject abusive inputs earlier, but risk
inventing constraints that real large games or mod packs exceed.
- Limits near `usize`/`u64` maxima avoid practical compatibility concerns, but
do not meaningfully bound memory or work.
- Configurable limits add settings and test combinations without a current user
need; protocol safety limits should be consistent between installations.
## 2026-08-09 — Track download ownership; never infer it from unknown files
**TL;DR:** Persist an atomic per-game set of successfully downloaded paths under
application state. On replacement, remove only paths in the previous owned set
that are absent from the new authoritative manifest. If old or corrupt state has
no trustworthy set, preserve unknown root files.
The existing game directory has no provenance information. Deleting every
non-reserved path would remove stale downloads, but could also delete a user's
own files and contradict the plan's preservation rule. The first update after
upgrading may therefore leave legacy stale files; subsequent successful
downloads have exact ownership and can clean safely.
Alternatives:
- Bootstrap ownership from the current directory. This is seamless, but can
permanently misclassify user files as Lanspread-owned and later delete them.
- Delete every non-reserved file missing from the new manifest. This is simple
and cleans legacy state completely, but is unsafe for user files.
- Delete only familiar archive suffixes such as `.eti`. This avoids most user
files, but bakes package naming guesses into the security boundary and cannot
clean general catalog payloads.
- Never clean stale files. This is safest for user data, but fails the explicit
successful-replacement requirement and can keep serving obsolete payload.
## 2026-08-09 — Generate production manifests outside the source checkout
**TL;DR:** Provide a small deterministic generator binary that runs next to the
canonical production packages and writes the JSON manifest artifacts that are
then stored and packaged with Lanspread.
This checkout has catalog metadata for 186 games but canonical package bytes
only for a small CLI fixture set. Hashes cannot be derived honestly without the
bytes. Keeping the generator here makes the format reproducible without
requiring production game packages in Git.
Alternatives:
- Store production packages in this repository. That would make generation
self-contained, but is impractical for size and distribution reasons.
- Generate hashes at application runtime. This would make local peer bytes the
authority, defeating catalog-owned verification and adding startup work.
- Accept hashes announced by peers. This is easy to deploy, but provides no
protection against a peer that supplies both the bytes and their claimed hash.