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:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user