fix(peer): confine download mutations to game root handles
Remote manifests were validated before mutation, but preparation, chunk writes, sentinel transactions, and ownership recovery later reopened ambient paths. A link or reparse-point swap between those steps could redirect a mutation outside the validated game root. Introduce a retained ConfinedGameRoot capability backed by cap-primitives. Carry typed validated destinations into chunk plans, walk every component without following links, and perform payload, sentinel, stale-file, abort, and recovery mutations relative to the retained handle. File writes and verification use the same opened handle, while final durability syncs payload files and unique parent directories before committing version.ini. Make ownership-record publication phase-aware as well. A directory-sync failure after record rename now stops before payload mutation without performing an unsafe old-sentinel rollback. Record the capability-root, bounded-handle, hard-link, and unproven Windows durability tradeoffs in the decision log. Test Plan: - `just clippy` -- passed - `just test` -- passed; 185 peer tests and the full workspace are green - `just fmt` -- Rust, TOML, and Prettier completed; command remains nonzero on 39 pre-existing rumdl issues outside this change - `git diff --cached --check` -- passed
This commit is contained in:
16 files changed
+1325
-591
No files matched your search
@@ -1,13 +1,13 @@
|
||||
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,
|
||||
ownership::DownloadOwnershipTransaction,
|
||||
planning::{ChunkDownloadResult, DownloadChunk, build_peer_plans, extract_version_descriptor},
|
||||
confined_fs::ConfinedGameRoot,
|
||||
manifest::{ValidatedDownloadEntry, ValidatedDownloadManifest},
|
||||
ownership::{DownloadOwnershipTransaction, OwnershipJournalPublication},
|
||||
planning::{ChunkDownloadResult, DownloadChunk, build_peer_plans},
|
||||
progress::{DownloadProgressTracker, sample_download_progress},
|
||||
retry::{RetryContext, retry_failed_chunks},
|
||||
storage::{prepare_game_storage, sync_game_storage},
|
||||
@@ -33,7 +33,6 @@ pub(crate) async fn download_game_files(
|
||||
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}");
|
||||
}
|
||||
@@ -42,17 +41,18 @@ pub(crate) async fn download_game_files(
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
|
||||
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 = manifest.game_root().to_path_buf();
|
||||
let ownership = DownloadOwnershipTransaction::prepare(state_dir, &manifest).await?;
|
||||
let version_entry = manifest.version_entry();
|
||||
let version_buffer =
|
||||
match VersionIniBuffer::new(version_entry.protocol_path(), version_entry.size()) {
|
||||
Ok(buffer) => Arc::new(buffer),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let confined_root = ConfinedGameRoot::open_or_create(manifest.games_folder(), &game_id).await?;
|
||||
let ownership =
|
||||
DownloadOwnershipTransaction::prepare(state_dir, &manifest, &confined_root).await?;
|
||||
|
||||
if let Err(error) = begin_version_ini_transaction(&game_root).await {
|
||||
if let Err(restore_error) = restore_before_ownership_journal(&game_root).await {
|
||||
if let Err(error) = begin_version_ini_transaction(&confined_root).await {
|
||||
if let Err(restore_error) = restore_before_ownership_journal(&confined_root).await {
|
||||
return Err(error.wrap_err(format!(
|
||||
"sentinel parking failed and rollback also failed: {restore_error}"
|
||||
)));
|
||||
@@ -60,18 +60,29 @@ pub(crate) async fn download_game_files(
|
||||
return Err(error);
|
||||
}
|
||||
if cancel_token.is_cancelled() {
|
||||
restore_before_ownership_journal(&game_root).await?;
|
||||
restore_before_ownership_journal(&confined_root).await?;
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
if let Err(error) = ownership.journal_pending().await {
|
||||
if let Err(restore_error) = restore_before_ownership_journal(&game_root).await {
|
||||
return Err(error.wrap_err(format!(
|
||||
"ownership journal failed and sentinel restore also failed: {restore_error}"
|
||||
)));
|
||||
match ownership.journal_pending().await {
|
||||
Ok(OwnershipJournalPublication::Durable) => {}
|
||||
Ok(OwnershipJournalPublication::NeedsRecovery(error)) => {
|
||||
// The pending record is visible, so restoring the old sentinel
|
||||
// would make recovery mistake it for a landed new commit. Stop
|
||||
// before payload mutation and leave the phase unambiguous.
|
||||
return Err(eyre::eyre!(
|
||||
"pending download ownership was renamed but its durability could not be established: {error}"
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
if let Err(restore_error) = restore_before_ownership_journal(&confined_root).await {
|
||||
return Err(error.wrap_err(format!(
|
||||
"ownership journal failed and sentinel restore also failed: {restore_error}"
|
||||
)));
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(err) = prepare_game_storage(&manifest).await {
|
||||
if let Err(err) = prepare_game_storage(&manifest, &confined_root).await {
|
||||
abort_download_best_effort(&ownership, &game_id).await;
|
||||
if cancel_token.is_cancelled() {
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
@@ -90,10 +101,10 @@ pub(crate) async fn download_game_files(
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
let progress_tracker = DownloadProgressTracker::new(total_download_bytes(&transfer_descs));
|
||||
let progress_tracker = DownloadProgressTracker::new(total_download_bytes(manifest.entries()));
|
||||
let transfer_ctx = TransferContext {
|
||||
game_id: &game_id,
|
||||
games_folder: &games_folder,
|
||||
game_root: &confined_root,
|
||||
peers: &peers,
|
||||
file_peer_map: &file_peer_map,
|
||||
tx_notify_ui: &tx_notify_ui,
|
||||
@@ -105,7 +116,7 @@ pub(crate) async fn download_game_files(
|
||||
&game_id,
|
||||
progress_tracker,
|
||||
tx_notify_ui.clone(),
|
||||
download_transfer_chunks(&transfer_ctx, &transfer_descs),
|
||||
download_transfer_chunks(&transfer_ctx, manifest.entries()),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -119,7 +130,7 @@ pub(crate) async fn download_game_files(
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
|
||||
if let Err(error) = sync_game_storage(&manifest).await {
|
||||
if let Err(error) = sync_game_storage(&manifest, &confined_root).await {
|
||||
abort_download_best_effort(&ownership, &game_id).await;
|
||||
return Err(error.wrap_err("failed to make downloaded payload durable"));
|
||||
}
|
||||
@@ -137,7 +148,7 @@ pub(crate) async fn download_game_files(
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
|
||||
match commit_version_ini_buffer(&game_root, &version_buffer).await {
|
||||
match commit_version_ini_buffer(&confined_root, &version_buffer).await {
|
||||
Ok(VersionIniCommit::Durable) => {}
|
||||
Ok(VersionIniCommit::NeedsRecovery(error)) => {
|
||||
// The visible sentinel makes rollback unsafe. Keep pending ownership
|
||||
@@ -160,7 +171,7 @@ pub(crate) async fn download_game_files(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_before_ownership_journal(game_root: &Path) -> eyre::Result<()> {
|
||||
async fn restore_before_ownership_journal(game_root: &ConfinedGameRoot) -> eyre::Result<()> {
|
||||
restore_unjournaled_version_ini_transaction(game_root).await
|
||||
}
|
||||
|
||||
@@ -172,7 +183,7 @@ async fn abort_download_best_effort(ownership: &DownloadOwnershipTransaction, ga
|
||||
|
||||
struct TransferContext<'a> {
|
||||
game_id: &'a str,
|
||||
games_folder: &'a Path,
|
||||
game_root: &'a ConfinedGameRoot,
|
||||
peers: &'a [SocketAddr],
|
||||
file_peer_map: &'a HashMap<String, Vec<SocketAddr>>,
|
||||
tx_notify_ui: &'a UnboundedSender<PeerEvent>,
|
||||
@@ -183,13 +194,13 @@ struct TransferContext<'a> {
|
||||
|
||||
async fn download_transfer_chunks(
|
||||
ctx: &TransferContext<'_>,
|
||||
transfer_descs: &[GameFileDescription],
|
||||
transfer_descs: &[ValidatedDownloadEntry],
|
||||
) -> eyre::Result<()> {
|
||||
let plans = build_peer_plans(ctx.peers, transfer_descs, ctx.file_peer_map);
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for (peer_addr, plan) in plans {
|
||||
let base_dir = ctx.games_folder.to_path_buf();
|
||||
let game_root = ctx.game_root.clone();
|
||||
let game_id = ctx.game_id.to_string();
|
||||
let cancel_token = ctx.cancel_token.clone();
|
||||
let version_buffer = ctx.version_buffer.clone();
|
||||
@@ -199,7 +210,7 @@ async fn download_transfer_chunks(
|
||||
peer_addr,
|
||||
&game_id,
|
||||
plan,
|
||||
base_dir,
|
||||
game_root,
|
||||
&cancel_token,
|
||||
Some(version_buffer),
|
||||
progress_tracker,
|
||||
@@ -266,7 +277,7 @@ fn collect_chunk_results(
|
||||
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished {
|
||||
id: game_id.to_string(),
|
||||
peer_addr: chunk_result.peer_addr,
|
||||
relative_path: chunk_result.chunk.relative_path,
|
||||
relative_path: chunk_result.chunk.request_path,
|
||||
offset: chunk_result.chunk.offset,
|
||||
length: chunk_result.chunk.length,
|
||||
});
|
||||
@@ -284,7 +295,7 @@ fn collect_chunk_results(
|
||||
} else {
|
||||
*last_err = Some(eyre::eyre!(
|
||||
"Max retries exceeded for chunk: {}",
|
||||
chunk_result.chunk.relative_path
|
||||
chunk_result.chunk.request_path
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -305,7 +316,7 @@ async fn retry_chunks(
|
||||
|
||||
let retry_ctx = RetryContext {
|
||||
peers: ctx.peers,
|
||||
base_dir: ctx.games_folder,
|
||||
game_root: ctx.game_root,
|
||||
game_id: ctx.game_id,
|
||||
file_peer_map: ctx.file_peer_map,
|
||||
cancel_token: ctx.cancel_token,
|
||||
@@ -335,7 +346,7 @@ async fn retry_chunks(
|
||||
.send(PeerEvent::DownloadGameFileChunkFinished {
|
||||
id: ctx.game_id.to_string(),
|
||||
peer_addr: chunk_result.peer_addr,
|
||||
relative_path: chunk_result.chunk.relative_path,
|
||||
relative_path: chunk_result.chunk.request_path,
|
||||
offset: chunk_result.chunk.offset,
|
||||
length: chunk_result.chunk.length,
|
||||
});
|
||||
@@ -350,9 +361,9 @@ async fn retry_chunks(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn total_download_bytes(file_descs: &[GameFileDescription]) -> u64 {
|
||||
fn total_download_bytes(file_descs: &[ValidatedDownloadEntry]) -> u64 {
|
||||
file_descs
|
||||
.iter()
|
||||
.filter(|desc| !desc.is_dir)
|
||||
.fold(0u64, |total, desc| total.saturating_add(desc.file_size()))
|
||||
.filter(|entry| !entry.is_dir())
|
||||
.fold(0u64, |total, entry| total.saturating_add(entry.size()))
|
||||
}
|
||||
Reference in new issue
Block a user