feat(peer): validate manifests before download mutation

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

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

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

No files matched your search

@@ -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}");
}