diff --git a/Cargo.lock b/Cargo.lock index ce5b2a5..f18402a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -278,6 +284,35 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cap-fs-ext" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78e5a3368ae89b7cb68186411452b4b9fac8b41be9c19bf3f47c2d2c8e36e6b" +dependencies = [ + "cap-primitives", + "io-lifetimes 3.0.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "cap-primitives" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdadbd7c002d3a484b35243669abdae85a0ebaded5a61117169dc3400f9a7ff0" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + [[package]] name = "cargo-platform" version = "0.1.9" @@ -972,6 +1007,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -1758,6 +1804,28 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4275b20e6057cd7733fd8df8a5a31701e4fe44497dad0f3fa0e1c4fb971506be" +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + [[package]] name = "ipnet" version = "2.12.1" @@ -1965,6 +2033,8 @@ name = "lanspread-peer" version = "0.1.0" dependencies = [ "bytes", + "cap-fs-ext", + "cap-primitives", "crc32fast", "eyre", "futures", @@ -2170,6 +2240,12 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "mdns-sd" version = "0.20.3" @@ -3088,6 +3164,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + [[package]] name = "rustls" version = "0.23.43" @@ -5634,6 +5720,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.13.1", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index 160bd8f..71956cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,8 @@ members = [ [workspace.dependencies] base64 = "0.23" bytes = { version = "1", features = ["serde"] } +cap-fs-ext = { version = "4", default-features = false } +cap-primitives = "4" crc32fast = "1" eyre = "0.6" futures = "0.3" diff --git a/crates/lanspread-peer/ARCHITECTURE.md b/crates/lanspread-peer/ARCHITECTURE.md index 13952a8..2fc92b4 100644 --- a/crates/lanspread-peer/ARCHITECTURE.md +++ b/crates/lanspread-peer/ARCHITECTURE.md @@ -159,8 +159,9 @@ Downloaded and installed are independent predicates: - `downloaded` is true only when `/version.ini` exists as a regular file. The sentinel is written last through `.version.ini.tmp` and atomic - rename. An interrupted replacement leaves no restored old sentinel because - archive bytes may already have changed. + rename. The old sentinel is parked before a pending ownership set is + published; recovery restores it only when a valid baseline proves payload + mutation never started. - `installed` is true when `/local/` is a directory. The contents of `local/` are user-owned and are skipped by manifests, fingerprints, and file serving. @@ -209,6 +210,14 @@ Most scans become O(number of game dirs), with full recursion only when needed. - Keep `GetGame`/manifest requests, but keyed by `manifest_hash` so repeated calls can be skipped when unchanged. +- The complete remote description is converted into a + `ValidatedDownloadManifest` before any destination mutation. It contains + canonical game-root-relative paths and rejects aliases, reserved state, shape + conflicts, and bounded-size violations as one unit. +- Download mutation holds a capability handle for the direct catalog game root. + Directory components and final files are reopened relative to that handle + without following links or Windows reparse points; chunk writes and checks use + the same opened file handle. - Downloads remain chunked QUIC streams with the existing integrity checks. - A game is transferable only when its ID is in the catalog, no operation is active for that ID, and the root-level `version.ini` sentinel exists. diff --git a/crates/lanspread-peer/Cargo.toml b/crates/lanspread-peer/Cargo.toml index 6437e69..4f4f6a7 100644 --- a/crates/lanspread-peer/Cargo.toml +++ b/crates/lanspread-peer/Cargo.toml @@ -14,6 +14,8 @@ lanspread-utils = { path = "../lanspread-utils" } # external bytes = { workspace = true } +cap-fs-ext = { workspace = true } +cap-primitives = { workspace = true } crc32fast = { workspace = true } eyre = { workspace = true } futures = { workspace = true } diff --git a/crates/lanspread-peer/src/download/confined_fs.rs b/crates/lanspread-peer/src/download/confined_fs.rs new file mode 100644 index 0000000..35ce9cf --- /dev/null +++ b/crates/lanspread-peer/src/download/confined_fs.rs @@ -0,0 +1,671 @@ +//! Handle-relative filesystem authority for peer download mutations. + +use std::{ + collections::BTreeSet, + fmt, + fs::File, + io::ErrorKind, + path::{Path, PathBuf}, + sync::Arc, +}; + +use cap_fs_ext::{ + FollowSymlinks, + OpenOptionsFollowExt, + OpenOptionsMaybeDirExt, + OpenOptionsSyncExt, +}; +use cap_primitives::{ + ambient_authority, + fs::{self, DirOptions, OpenOptions}, +}; + +use super::manifest::{ValidatedDownloadEntry, ValidatedDownloadPath}; + +#[derive(Clone)] +pub(super) struct ConfinedGameRoot { + inner: Arc, +} + +struct ConfinedGameRootInner { + game_root: File, + display_path: PathBuf, +} + +impl fmt::Debug for ConfinedGameRoot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ConfinedGameRoot") + .field("display_path", &self.inner.display_path) + .finish_non_exhaustive() + } +} + +impl ConfinedGameRoot { + pub(super) async fn open_or_create(games_folder: &Path, game_id: &str) -> eyre::Result { + let games_folder = games_folder.to_path_buf(); + let game_id = game_id.to_owned(); + tokio::task::spawn_blocking(move || Self::open_blocking(&games_folder, &game_id, true)) + .await? + } + + pub(super) async fn open_existing( + games_folder: &Path, + game_id: &str, + ) -> eyre::Result> { + let games_folder = games_folder.to_path_buf(); + let game_id = game_id.to_owned(); + tokio::task::spawn_blocking(move || Self::open_blocking(&games_folder, &game_id, false)) + .await + .map_err(Into::into) + .and_then(|result| match result { + Ok(root) => Ok(Some(root)), + Err(error) + if error + .downcast_ref::() + .is_some_and(|error| error.kind() == ErrorKind::NotFound) => + { + Ok(None) + } + Err(error) => Err(error), + }) + } + + fn open_blocking(games_folder: &Path, game_id: &str, create: bool) -> eyre::Result { + let games_dir = open_ambient_directory_nofollow(games_folder)?; + let game_component = Path::new(game_id); + let game_root = match fs::open(&games_dir, game_component, &directory_options()) { + Ok(root) => root, + Err(error) if create && error.kind() == ErrorKind::NotFound => { + match fs::create_dir(&games_dir, game_component, &DirOptions::new()) { + Ok(()) => sync_directory_handle(&games_dir)?, + Err(error) if error.kind() == ErrorKind::AlreadyExists => {} + Err(error) => return Err(error.into()), + } + fs::open(&games_dir, game_component, &directory_options())? + } + Err(error) => return Err(error.into()), + }; + validate_directory_handle(&game_root, "game root")?; + + Ok(Self { + inner: Arc::new(ConfinedGameRootInner { + game_root, + display_path: games_folder.join(game_id), + }), + }) + } + + pub(super) fn display_path(&self) -> &Path { + &self.inner.display_path + } + + pub(super) async fn prepare_entries( + &self, + entries: Vec, + ) -> eyre::Result<()> { + let root = self.clone(); + tokio::task::spawn_blocking(move || { + for entry in &entries { + if entry.is_dir() { + root.open_directory_blocking(entry.destination(), true)?; + } else { + root.prepare_file_blocking(entry.destination(), entry.size())?; + } + } + Ok(()) + }) + .await? + } + + pub(super) async fn open_chunk_file(&self, path: &ValidatedDownloadPath) -> eyre::Result { + let root = self.clone(); + let path = path.clone(); + tokio::task::spawn_blocking(move || root.open_regular_file_blocking(&path, false)).await? + } + + pub(super) async fn sync_entries( + &self, + entries: Vec, + ) -> eyre::Result<()> { + let root = self.clone(); + tokio::task::spawn_blocking(move || { + let mut parent_directories = BTreeSet::new(); + for entry in &entries { + if !entry.is_dir() { + root.open_regular_file_blocking(entry.destination(), false)? + .sync_all()?; + if let Some((parent, _)) = entry.destination().canonical().rsplit_once('/') { + parent_directories.insert(parent.to_owned()); + } + } + } + for parent in parent_directories { + let parent = ValidatedDownloadPath::from_ownership(&parent)?; + root.open_directory_blocking(&parent, false)?.sync_all()?; + } + sync_directory_handle(&root.inner.game_root)?; + Ok(()) + }) + .await? + } + + pub(super) async fn remove_owned_regular_files( + &self, + paths: Vec, + ) -> eyre::Result<()> { + let root = self.clone(); + tokio::task::spawn_blocking(move || { + for path in &paths { + root.remove_owned_regular_file_blocking(path)?; + } + Ok(()) + }) + .await? + } + + pub(super) async fn root_regular_file_exists(&self, name: &'static str) -> eyre::Result { + let root = self.clone(); + tokio::task::spawn_blocking( + move || match root.inspect_root_regular_file_blocking(name) { + Ok(_) => Ok(true), + Err(error) + if error + .downcast_ref::() + .is_some_and(|error| error.kind() == ErrorKind::NotFound) => + { + Ok(false) + } + Err(error) => Err(error), + }, + ) + .await? + } + + pub(super) async fn create_new_root_file(&self, name: &'static str) -> eyre::Result { + let root = self.clone(); + tokio::task::spawn_blocking(move || root.create_new_root_file_blocking(name)).await? + } + + pub(super) async fn remove_root_file_if_exists(&self, name: &'static str) -> eyre::Result<()> { + let root = self.clone(); + tokio::task::spawn_blocking(move || { + match fs::remove_file(&root.inner.game_root, Path::new(name)) { + Ok(()) => { + sync_directory_handle(&root.inner.game_root)?; + Ok(()) + } + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } + }) + .await? + } + + pub(super) async fn rename_root_file( + &self, + source: &'static str, + destination: &'static str, + ) -> eyre::Result<()> { + let root = self.clone(); + tokio::task::spawn_blocking(move || { + fs::rename( + &root.inner.game_root, + Path::new(source), + &root.inner.game_root, + Path::new(destination), + )?; + Ok(()) + }) + .await? + } + + pub(super) async fn sync_root(&self) -> std::io::Result<()> { + let root = self.clone(); + tokio::task::spawn_blocking(move || sync_directory_handle(&root.inner.game_root)) + .await + .map_err(std::io::Error::other)? + } + + fn open_directory_blocking( + &self, + path: &ValidatedDownloadPath, + create: bool, + ) -> eyre::Result { + let mut current = self.inner.game_root.try_clone()?; + for component in path.components() { + current = open_directory_component(¤t, component, create)?; + } + Ok(current) + } + + fn open_regular_file_blocking( + &self, + path: &ValidatedDownloadPath, + create: bool, + ) -> eyre::Result { + let (parent, leaf) = self.open_parent_blocking(path, create)?; + open_regular_file_at(&parent, leaf, create) + } + + fn prepare_file_blocking(&self, path: &ValidatedDownloadPath, size: u64) -> eyre::Result<()> { + let (parent, leaf) = self.open_parent_blocking(path, true)?; + let file = open_regular_file_at(&parent, leaf, true)?; + file.set_len(size)?; + Ok(()) + } + + fn inspect_root_regular_file_blocking(&self, name: &'static str) -> eyre::Result { + let options = inspection_file_options(); + let file = fs::open(&self.inner.game_root, Path::new(name), &options)?; + validate_regular_file_handle(&file, name)?; + Ok(file) + } + + fn create_new_root_file_blocking(&self, name: &'static str) -> eyre::Result { + let mut options = regular_file_options(); + options.create_new(true); + let file = fs::open(&self.inner.game_root, Path::new(name), &options)?; + validate_regular_file_handle(&file, name)?; + Ok(file) + } + + fn open_parent_blocking<'a>( + &self, + path: &'a ValidatedDownloadPath, + create: bool, + ) -> eyre::Result<(File, &'a str)> { + let mut components = path.components().peekable(); + let mut current = self.inner.game_root.try_clone()?; + while let Some(component) = components.next() { + if components.peek().is_none() { + return Ok((current, component)); + } + current = open_directory_component(¤t, component, create)?; + } + unreachable!("validated paths contain one component") + } + + fn remove_owned_regular_file_blocking(&self, path: &ValidatedDownloadPath) -> eyre::Result<()> { + let Some((parent, leaf)) = self.open_parent_for_cleanup_blocking(path)? else { + return Ok(()); + }; + + let metadata = match fs::stat(&parent, Path::new(leaf), FollowSymlinks::No) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + if !metadata.is_file() || metadata.file_type().is_symlink() { + log::warn!( + "Preserving owned path whose shape changed at {}/{}", + self.inner.display_path.display(), + path.canonical() + ); + return Ok(()); + } + + // Opening and inspecting the same object before unlink catches Windows + // reparse points that are not reported as ordinary symlinks. + let file = match inspect_regular_file_at(&parent, leaf) { + Ok(file) => file, + Err(error) if is_preservable_shape_error(&error) => { + log::warn!( + "Preserving owned path whose final object changed at {}/{}", + self.inner.display_path.display(), + path.canonical() + ); + return Ok(()); + } + Err(error) => return Err(error), + }; + drop(file); + fs::remove_file(&parent, Path::new(leaf))?; + sync_directory_handle(&parent)?; + Ok(()) + } + + fn open_parent_for_cleanup_blocking<'a>( + &self, + path: &'a ValidatedDownloadPath, + ) -> eyre::Result> { + let mut components = path.components().peekable(); + let mut current = self.inner.game_root.try_clone()?; + while let Some(component) = components.next() { + if components.peek().is_none() { + return Ok(Some((current, component))); + } + + let metadata = match fs::stat(¤t, Path::new(component), FollowSymlinks::No) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + log::warn!( + "Preserving owned path with a changed parent at {}/{}", + self.inner.display_path.display(), + path.canonical() + ); + return Ok(None); + } + + let directory = match fs::open(¤t, Path::new(component), &directory_options()) { + Ok(directory) => directory, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + if let Err(error) = validate_directory_handle(&directory, component) { + if error.kind() == ErrorKind::InvalidInput { + log::warn!( + "Preserving owned path with a changed parent at {}/{}", + self.inner.display_path.display(), + path.canonical() + ); + return Ok(None); + } + return Err(error.into()); + } + current = directory; + } + unreachable!("validated paths contain one component") + } +} + +fn open_ambient_directory_nofollow(path: &Path) -> eyre::Result { + let mut options = OpenOptions::new(); + options.read(true); + options + .maybe_dir(true) + .follow(FollowSymlinks::No) + .nonblock(true); + let directory = fs::open_ambient(path, &options, ambient_authority())?; + validate_directory_handle(&directory, &path.display().to_string())?; + Ok(directory) +} + +fn open_directory_component(parent: &File, component: &str, create: bool) -> eyre::Result { + let component = Path::new(component); + let directory = match fs::open(parent, component, &directory_options()) { + Ok(directory) => directory, + Err(error) if create && error.kind() == ErrorKind::NotFound => { + match fs::create_dir(parent, component, &DirOptions::new()) { + Ok(()) => sync_directory_handle(parent)?, + Err(error) if error.kind() == ErrorKind::AlreadyExists => {} + Err(error) => return Err(error.into()), + } + fs::open(parent, component, &directory_options())? + } + Err(error) => return Err(error.into()), + }; + validate_directory_handle(&directory, &component.display().to_string())?; + Ok(directory) +} + +fn open_regular_file_at(parent: &File, leaf: &str, create: bool) -> eyre::Result { + let mut options = regular_file_options(); + options.create(create); + let file = fs::open(parent, Path::new(leaf), &options)?; + validate_regular_file_handle(&file, leaf)?; + Ok(file) +} + +fn inspect_regular_file_at(parent: &File, leaf: &str) -> eyre::Result { + let options = inspection_file_options(); + let file = fs::open(parent, Path::new(leaf), &options)?; + validate_regular_file_handle(&file, leaf)?; + Ok(file) +} + +fn regular_file_options() -> OpenOptions { + let mut options = OpenOptions::new(); + options.read(true).write(true).truncate(false); + options.follow(FollowSymlinks::No).nonblock(true); + options +} + +fn directory_options() -> OpenOptions { + let mut options = OpenOptions::new(); + options.read(true); + options + .maybe_dir(true) + .follow(FollowSymlinks::No) + .nonblock(true); + options +} + +fn inspection_file_options() -> OpenOptions { + let mut options = OpenOptions::new(); + options.read(true); + options.follow(FollowSymlinks::No).nonblock(true); + options +} + +fn validate_directory_handle(file: &File, display: &str) -> std::io::Result<()> { + let metadata = file.metadata()?; + if !metadata.is_dir() { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!("download destination is not a directory: {display}"), + )); + } + reject_windows_reparse(&metadata, display) +} + +fn validate_regular_file_handle(file: &File, display: &str) -> std::io::Result<()> { + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!("download destination is not a regular file: {display}"), + )); + } + reject_windows_reparse(&metadata, display) +} + +#[cfg(windows)] +fn reject_windows_reparse(metadata: &std::fs::Metadata, display: &str) -> std::io::Result<()> { + use std::os::windows::fs::MetadataExt as _; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!("download destination is a Windows reparse point: {display}"), + )); + } + Ok(()) +} + +#[cfg(not(windows))] +#[allow(clippy::unnecessary_wraps)] +const fn reject_windows_reparse( + _metadata: &std::fs::Metadata, + _display: &str, +) -> std::io::Result<()> { + Ok(()) +} + +fn is_preservable_shape_error(error: &eyre::Report) -> bool { + error.downcast_ref::().is_some_and(|error| { + matches!( + error.kind(), + ErrorKind::NotFound | ErrorKind::NotADirectory | ErrorKind::InvalidInput + ) + }) +} + +#[cfg(unix)] +fn sync_directory_handle(directory: &File) -> std::io::Result<()> { + directory.sync_all() +} + +#[cfg(not(unix))] +const fn sync_directory_handle(_directory: &File) -> std::io::Result<()> { + // Rust does not expose a portable durable directory flush on Windows. + // File contents are still synced; rename recovery remains process-crash + // safe. Power-loss durability needs real NTFS evidence before claiming it. + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::io::{Seek, SeekFrom, Write}; + + use super::*; + use crate::test_support::TempDir; + + fn path(value: &str) -> ValidatedDownloadPath { + ValidatedDownloadPath::from_ownership(value).expect("test path should validate") + } + + #[tokio::test] + async fn prepares_and_reopens_nested_regular_file() { + let games = TempDir::new("lanspread-confined-basic"); + let root = ConfinedGameRoot::open_or_create(games.path(), "game") + .await + .expect("game root should open"); + let destination = path("nested/payload.bin"); + + root.prepare_file_blocking(&destination, 4) + .expect("file should prepare"); + let mut file = root + .open_chunk_file(&destination) + .await + .expect("file should reopen"); + file.seek(SeekFrom::Start(0)).expect("seek should succeed"); + file.write_all(b"data").expect("write should succeed"); + file.sync_all().expect("file should sync"); + + assert_eq!( + std::fs::read(games.game_root().join("nested/payload.bin")) + .expect("payload should be readable"), + b"data" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn intermediate_and_final_symlinks_never_redirect_preparation() { + use std::os::unix::fs::symlink; + + let games = TempDir::new("lanspread-confined-links"); + let outside = TempDir::new("lanspread-confined-outside"); + let root = ConfinedGameRoot::open_or_create(games.path(), "game") + .await + .expect("game root should open"); + std::fs::write(outside.path().join("canary"), b"outside") + .expect("canary should be written"); + symlink(outside.path(), games.game_root().join("linked")) + .expect("intermediate link should be created"); + + assert!( + root.prepare_file_blocking(&path("linked/canary"), 0) + .is_err() + ); + + symlink( + outside.path().join("canary"), + games.game_root().join("leaf"), + ) + .expect("leaf link should be created"); + assert!(root.prepare_file_blocking(&path("leaf"), 0).is_err()); + assert_eq!( + std::fs::read(outside.path().join("canary")).expect("canary should be readable"), + b"outside" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn writes_remain_on_open_handle_after_path_swap() { + use std::os::unix::fs::symlink; + + let games = TempDir::new("lanspread-confined-swap"); + let outside = TempDir::new("lanspread-confined-swap-outside"); + let root = ConfinedGameRoot::open_or_create(games.path(), "game") + .await + .expect("game root should open"); + let destination = path("payload.bin"); + root.prepare_file_blocking(&destination, 4) + .expect("file should prepare"); + let mut open_file = root + .open_chunk_file(&destination) + .await + .expect("file should open"); + std::fs::write(outside.path().join("canary"), b"safe").expect("canary should be written"); + std::fs::rename( + games.game_root().join("payload.bin"), + games.game_root().join("original.bin"), + ) + .expect("payload path should move"); + symlink( + outside.path().join("canary"), + games.game_root().join("payload.bin"), + ) + .expect("replacement link should be created"); + + open_file + .seek(SeekFrom::Start(0)) + .expect("seek should succeed"); + open_file.write_all(b"data").expect("write should succeed"); + open_file.sync_all().expect("file should sync"); + + assert_eq!( + std::fs::read(games.game_root().join("original.bin")) + .expect("original inode should be readable"), + b"data" + ); + assert_eq!( + std::fs::read(outside.path().join("canary")).expect("canary should be readable"), + b"safe" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn retained_root_handle_survives_ambient_path_swap() { + use std::os::unix::fs::symlink; + + let games = TempDir::new("lanspread-confined-root-swap"); + let outside = TempDir::new("lanspread-confined-root-swap-outside"); + let root = ConfinedGameRoot::open_or_create(games.path(), "game") + .await + .expect("game root should open"); + std::fs::write(outside.path().join("canary"), b"safe").expect("canary should be written"); + std::fs::rename(games.game_root(), games.path().join("held-root")) + .expect("game root path should move"); + symlink(outside.path(), games.game_root()).expect("replacement link should be created"); + + root.prepare_file_blocking(&path("payload.bin"), 4) + .expect("retained root should stay usable"); + + assert!(games.path().join("held-root/payload.bin").is_file()); + assert_eq!( + std::fs::read(outside.path().join("canary")).expect("canary should be readable"), + b"safe" + ); + assert!(!outside.path().join("payload.bin").exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn cleanup_can_inspect_and_remove_read_only_owned_files() { + use std::os::unix::fs::PermissionsExt as _; + + let games = TempDir::new("lanspread-confined-read-only-cleanup"); + let root = ConfinedGameRoot::open_or_create(games.path(), "game") + .await + .expect("game root should open"); + let payload = games.game_root().join("payload.bin"); + std::fs::write(&payload, b"owned").expect("payload should be written"); + std::fs::set_permissions(&payload, std::fs::Permissions::from_mode(0o444)) + .expect("payload should become read-only"); + + root.remove_owned_regular_files(vec![path("payload.bin")]) + .await + .expect("read-only owned file should be removable"); + + assert!(!payload.exists()); + } +} diff --git a/crates/lanspread-peer/src/download/manifest.rs b/crates/lanspread-peer/src/download/manifest.rs index 58c833b..5783c61 100644 --- a/crates/lanspread-peer/src/download/manifest.rs +++ b/crates/lanspread-peer/src/download/manifest.rs @@ -26,19 +26,18 @@ const MAX_DOWNLOAD_DESTINATION_UNITS: usize = 1_000; /// One entry whose path and shape were validated as part of a complete manifest. #[derive(Clone, Debug)] pub(crate) struct ValidatedDownloadEntry { - canonical_path: String, + destination: ValidatedDownloadPath, protocol_path: String, is_dir: bool, size: u64, } impl ValidatedDownloadEntry { - pub(crate) fn canonical_path(&self) -> &str { - &self.canonical_path + pub(super) const fn destination(&self) -> &ValidatedDownloadPath { + &self.destination } - #[cfg(test)] - fn protocol_path(&self) -> &str { + pub(super) fn protocol_path(&self) -> &str { &self.protocol_path } @@ -51,7 +50,7 @@ impl ValidatedDownloadEntry { } pub(crate) fn is_version_ini(&self) -> bool { - self.canonical_path == VERSION_INI + self.destination.canonical() == VERSION_INI } pub(crate) fn protocol_description(&self, game_id: &str) -> GameFileDescription { @@ -62,6 +61,42 @@ impl ValidatedDownloadEntry { size: self.size, } } + + #[cfg(test)] + pub(super) fn test_file(protocol_path: &str, canonical_path: &str, size: u64) -> Self { + validate_canonical_path(canonical_path).expect("test path should be canonical"); + Self { + destination: ValidatedDownloadPath::new(canonical_path.to_owned()), + protocol_path: protocol_path.to_owned(), + is_dir: false, + size, + } + } +} + +/// A canonical root-relative path that passed the complete download policy. +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(super) struct ValidatedDownloadPath { + canonical: String, +} + +impl ValidatedDownloadPath { + fn new(canonical: String) -> Self { + Self { canonical } + } + + pub(super) fn from_ownership(path: &str) -> eyre::Result { + validate_owned_file_path(path)?; + Ok(Self::new(path.to_owned())) + } + + pub(super) fn canonical(&self) -> &str { + &self.canonical + } + + pub(super) fn components(&self) -> impl DoubleEndedIterator { + self.canonical.split('/') + } } /// A complete download description validated before any filesystem mutation. @@ -69,7 +104,6 @@ impl ValidatedDownloadEntry { pub(crate) struct ValidatedDownloadManifest { game_id: String, games_folder: PathBuf, - game_root: PathBuf, entries: Vec, } @@ -105,7 +139,6 @@ impl ValidatedDownloadManifest { Ok(Self { game_id: game_id.to_owned(), games_folder, - game_root, entries, }) } @@ -118,12 +151,7 @@ impl ValidatedDownloadManifest { &self.games_folder } - pub(crate) fn game_root(&self) -> &Path { - &self.game_root - } - - #[cfg(test)] - fn entries(&self) -> &[ValidatedDownloadEntry] { + pub(super) fn entries(&self) -> &[ValidatedDownloadEntry] { &self.entries } @@ -131,17 +159,17 @@ impl ValidatedDownloadManifest { self.entries.iter().filter(|entry| !entry.is_version_ini()) } - pub(crate) fn protocol_descriptions(&self) -> Vec { + pub(super) fn version_entry(&self) -> &ValidatedDownloadEntry { self.entries .iter() - .map(|entry| entry.protocol_description(&self.game_id)) - .collect() + .find(|entry| entry.is_version_ini()) + .expect("validated manifests contain one version.ini") } pub(super) fn owned_file_paths(&self) -> Vec { self.transfer_entries() .filter(|entry| !entry.is_dir()) - .map(|entry| entry.canonical_path.clone()) + .map(|entry| entry.destination.canonical.clone()) .collect() } } @@ -249,7 +277,7 @@ impl<'a> ProtocolV7ManifestBuilder<'a> { )?; } self.entries.push(ValidatedDownloadEntry { - canonical_path: canonical_path.to_owned(), + destination: ValidatedDownloadPath::new(canonical_path.to_owned()), protocol_path: description.relative_path, is_dir: description.is_dir, size: description.size, @@ -325,7 +353,7 @@ impl<'a> ProtocolV7ManifestBuilder<'a> { fn finish(mut self) -> eyre::Result> { self.entries - .sort_by(|left, right| left.canonical_path.cmp(&right.canonical_path)); + .sort_by(|left, right| left.destination.cmp(&right.destination)); let versions = self .entries .iter() @@ -720,7 +748,7 @@ mod tests { let paths = manifest .entries() .iter() - .map(ValidatedDownloadEntry::canonical_path) + .map(|entry| entry.destination().canonical()) .collect::>(); assert_eq!(paths, ["archive.eti", "version.ini"]); let version_ini = manifest diff --git a/crates/lanspread-peer/src/download/mod.rs b/crates/lanspread-peer/src/download/mod.rs index 437324e..6afad8b 100644 --- a/crates/lanspread-peer/src/download/mod.rs +++ b/crates/lanspread-peer/src/download/mod.rs @@ -1,5 +1,6 @@ //! Download pipeline for game files from peers. +mod confined_fs; mod manifest; mod orchestrator; mod ownership; diff --git a/crates/lanspread-peer/src/download/orchestrator.rs b/crates/lanspread-peer/src/download/orchestrator.rs index eb68402..6c14dd4 100644 --- a/crates/lanspread-peer/src/download/orchestrator.rs +++ b/crates/lanspread-peer/src/download/orchestrator.rs @@ -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>, tx_notify_ui: &'a UnboundedSender, @@ -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())) } diff --git a/crates/lanspread-peer/src/download/ownership.rs b/crates/lanspread-peer/src/download/ownership.rs index 282bd60..b78e050 100644 --- a/crates/lanspread-peer/src/download/ownership.rs +++ b/crates/lanspread-peer/src/download/ownership.rs @@ -6,14 +6,17 @@ use std::{ path::{Path, PathBuf}, }; +use eyre::WrapErr as _; use serde::{Deserialize, Serialize}; use tokio::io::AsyncWriteExt; use super::{ + confined_fs::ConfinedGameRoot, manifest::{ MAX_DOWNLOAD_MANIFEST_ENTRIES, MAX_DOWNLOAD_RELATIVE_PATH_BYTES, ValidatedDownloadManifest, + ValidatedDownloadPath, validate_owned_file_path, }, version_ini::{ @@ -82,6 +85,13 @@ enum LoadedOwnership { Valid(DownloadOwnershipRecord), } +pub(super) enum OwnershipJournalPublication { + Durable, + /// The new record is visible, but its directory entry may not survive a + /// power loss. Callers must not treat this as a pre-publication failure. + NeedsRecovery(std::io::Error), +} + /// One download attempt whose previous and proposed ownership sets are durable. #[derive(Debug)] pub(super) struct DownloadOwnershipTransaction { @@ -89,7 +99,7 @@ pub(super) struct DownloadOwnershipTransaction { tmp_path: PathBuf, game_id: String, games_folder_key: String, - game_root: PathBuf, + game_root: ConfinedGameRoot, previous: BTreeSet, current: BTreeSet, } @@ -99,10 +109,17 @@ impl DownloadOwnershipTransaction { pub(super) async fn prepare( state_dir: &Path, manifest: &ValidatedDownloadManifest, + game_root: &ConfinedGameRoot, ) -> eyre::Result { - recover_incomplete_download(manifest.game_root(), state_dir, manifest.game_id()).await?; - let games_folder_key = games_folder_key(manifest.games_folder()); + recover_incomplete_download_with_root( + game_root, + state_dir, + manifest.game_id(), + &games_folder_key, + ) + .await?; + let record_path = download_ownership_path(state_dir, manifest.game_id()); let tmp_path = download_ownership_tmp_path(state_dir, manifest.game_id()); let previous = match load_record(&record_path, manifest.game_id(), &games_folder_key).await @@ -111,7 +128,10 @@ impl DownloadOwnershipTransaction { LoadedOwnership::Missing | LoadedOwnership::Invalid => { let baseline = DownloadOwnershipRecord::empty(manifest.game_id(), &games_folder_key); - write_record(&record_path, &tmp_path, &baseline).await?; + require_durable_record( + write_record(&record_path, &tmp_path, &baseline).await?, + "download ownership baseline", + )?; BTreeSet::new() } }; @@ -123,14 +143,14 @@ impl DownloadOwnershipTransaction { tmp_path, game_id: manifest.game_id().to_owned(), games_folder_key, - game_root: manifest.game_root().to_path_buf(), + game_root: game_root.clone(), previous, current, }) } /// Publishes the proposed set after the old sentinel has been parked. - pub(super) async fn journal_pending(&self) -> eyre::Result<()> { + pub(super) async fn journal_pending(&self) -> eyre::Result { let record = DownloadOwnershipRecord { schema_version: OWNERSHIP_SCHEMA_VERSION, game_id: self.game_id.clone(), @@ -161,7 +181,10 @@ impl DownloadOwnershipTransaction { remove_owned_files(&self.game_root, &removable).await?; discard_version_ini_transaction(&self.game_root).await?; let empty = DownloadOwnershipRecord::empty(&self.game_id, &self.games_folder_key); - write_record(&self.record_path, &self.tmp_path, &empty).await + require_durable_record( + write_record(&self.record_path, &self.tmp_path, &empty).await?, + "aborted download ownership", + ) } /// Finalizes ownership after the new `version.ini` commit point has landed. @@ -173,7 +196,10 @@ impl DownloadOwnershipTransaction { committed_files: self.current.iter().cloned().collect(), pending_files: None, }; - write_record(&self.record_path, &self.tmp_path, &record).await + require_durable_record( + write_record(&self.record_path, &self.tmp_path, &record).await?, + "finalized download ownership", + ) } } @@ -194,11 +220,29 @@ pub(crate) async fn recover_incomplete_download( Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()), Err(error) => return Err(error.into()), }; + if game_root.file_name() != Some(std::ffi::OsStr::new(game_id)) { + eyre::bail!( + "game root is not the requested direct catalog child: {}", + game_root.display() + ); + } + let Some(game_root) = ConfinedGameRoot::open_existing(&games_folder, game_id).await? else { + return Ok(()); + }; let key = games_folder_key(&games_folder); + recover_incomplete_download_with_root(&game_root, state_dir, game_id, &key).await +} + +async fn recover_incomplete_download_with_root( + game_root: &ConfinedGameRoot, + state_dir: &Path, + game_id: &str, + games_folder_key: &str, +) -> eyre::Result<()> { let path = download_ownership_path(state_dir, game_id); let tmp_path = download_ownership_tmp_path(state_dir, game_id); - match load_record(&path, game_id, &key).await { + match load_record(&path, game_id, games_folder_key).await { LoadedOwnership::Missing => { // Pre-journal versions used the same scratch name after payload // mutation. Without a new-format baseline, restoring it could @@ -222,7 +266,10 @@ pub(crate) async fn recover_incomplete_download( .cloned() .collect::>(); let pending = pending.into_iter().collect::>(); - if version_ini_is_regular(game_root).await? { + if game_root + .root_regular_file_exists(crate::game_paths::VERSION_INI) + .await? + { let stale = committed .difference(&pending) .cloned() @@ -231,13 +278,19 @@ pub(crate) async fn recover_incomplete_download( finish_recovered_version_ini_transaction(game_root).await?; record.committed_files = pending.into_iter().collect(); record.pending_files = None; - write_record(&path, &tmp_path, &record).await?; + require_durable_record( + write_record(&path, &tmp_path, &record).await?, + "recovered committed download ownership", + )?; } else { let removable = committed.union(&pending).cloned().collect::>(); remove_owned_files(game_root, &removable).await?; discard_version_ini_transaction(game_root).await?; - let empty = DownloadOwnershipRecord::empty(game_id, &key); - write_record(&path, &tmp_path, &empty).await?; + let empty = DownloadOwnershipRecord::empty(game_id, games_folder_key); + require_durable_record( + write_record(&path, &tmp_path, &empty).await?, + "recovered aborted download ownership", + )?; } } } @@ -355,7 +408,7 @@ async fn write_record( path: &Path, tmp_path: &Path, record: &DownloadOwnershipRecord, -) -> eyre::Result<()> { +) -> eyre::Result { write_record_with_parent_sync(path, tmp_path, record, sync_parent_dir).await } @@ -364,7 +417,7 @@ async fn write_record_with_parent_sync( tmp_path: &Path, record: &DownloadOwnershipRecord, sync_parent: impl FnOnce(&Path) -> std::io::Result<()>, -) -> eyre::Result<()> { +) -> eyre::Result { let parent = path .parent() .ok_or_else(|| eyre::eyre!("download ownership path has no parent"))?; @@ -380,62 +433,32 @@ async fn write_record_with_parent_sync( drop(file); tokio::fs::rename(tmp_path, path).await?; if let Err(error) = sync_parent(path) { - // The rename is the publication point. Reporting an error from here - // would let callers incorrectly roll back a record that is already - // visible, making the journal state ambiguous to recovery. - log::warn!( - "Published download ownership {} but failed to sync its parent: {error}", - path.display() - ); + return Ok(OwnershipJournalPublication::NeedsRecovery(error)); } - Ok(()) + Ok(OwnershipJournalPublication::Durable) } -async fn remove_owned_files(game_root: &Path, paths: &BTreeSet) -> eyre::Result<()> { - for relative_path in paths { - remove_owned_regular_file(game_root, relative_path).await?; +fn require_durable_record( + publication: OwnershipJournalPublication, + label: &str, +) -> eyre::Result<()> { + match publication { + OwnershipJournalPublication::Durable => Ok(()), + OwnershipJournalPublication::NeedsRecovery(error) => Err(error).wrap_err(format!( + "{label} was renamed but its durability could not be established" + )), } - Ok(()) } -async fn remove_owned_regular_file(game_root: &Path, relative_path: &str) -> eyre::Result { - validate_owned_file_path(relative_path)?; - let mut destination = game_root.to_path_buf(); - let components = relative_path.split('/').collect::>(); - for (index, component) in components.iter().enumerate() { - destination.push(component); - let metadata = match tokio::fs::symlink_metadata(&destination).await { - Ok(metadata) => metadata, - Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false), - Err(error) => return Err(error.into()), - }; - let is_final = index + 1 == components.len(); - if metadata.file_type().is_symlink() { - log::warn!( - "Preserving owned path with symlink component {}", - destination.display() - ); - return Ok(false); - } - if !is_final && !metadata.is_dir() { - log::warn!( - "Preserving owned path with non-directory parent {}", - destination.display() - ); - return Ok(false); - } - if is_final && !metadata.is_file() { - log::warn!( - "Preserving owned path whose shape changed at {}", - destination.display() - ); - return Ok(false); - } - } - - remove_file_if_exists(&destination).await?; - sync_parent_dir(&destination)?; - Ok(true) +async fn remove_owned_files( + game_root: &ConfinedGameRoot, + paths: &BTreeSet, +) -> eyre::Result<()> { + let paths = paths + .iter() + .map(|path| ValidatedDownloadPath::from_ownership(path)) + .collect::>>()?; + game_root.remove_owned_regular_files(paths).await } async fn create_state_parent_durably(path: &Path) -> eyre::Result<()> { @@ -467,14 +490,6 @@ async fn create_state_parent_durably(path: &Path) -> eyre::Result<()> { Ok(()) } -async fn version_ini_is_regular(game_root: &Path) -> eyre::Result { - match tokio::fs::symlink_metadata(game_root.join(crate::game_paths::VERSION_INI)).await { - Ok(metadata) => Ok(metadata.is_file() && !metadata.file_type().is_symlink()), - Err(error) if error.kind() == ErrorKind::NotFound => Ok(false), - Err(error) => Err(error.into()), - } -} - async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> { match tokio::fs::remove_file(path).await { Ok(()) => Ok(()), @@ -590,13 +605,17 @@ mod tests { committed_files: committed.iter().map(ToString::to_string).collect(), pending_files: pending.map(|paths| paths.iter().map(ToString::to_string).collect()), }; - write_record( - &download_ownership_path(state_dir, "game"), - &download_ownership_tmp_path(state_dir, "game"), - &record, + require_durable_record( + write_record( + &download_ownership_path(state_dir, "game"), + &download_ownership_tmp_path(state_dir, "game"), + &record, + ) + .await + .expect("record should be published"), + "test ownership", ) - .await - .expect("record should be written"); + .expect("record should be durable"); } async fn read_valid_record(state_dir: &Path, games_folder: &Path) -> DownloadOwnershipRecord { @@ -613,14 +632,22 @@ mod tests { } } + async fn confined_root(manifest: &ValidatedDownloadManifest) -> ConfinedGameRoot { + ConfinedGameRoot::open_or_create(manifest.games_folder(), manifest.game_id()) + .await + .expect("confined game root should open") + } + #[tokio::test] async fn journal_is_sorted_bound_and_ignores_stray_tmp() { let games = TempDir::new("lanspread-ownership-games"); let state = TempDir::new("lanspread-ownership-state"); let manifest = manifest(games.path(), &["z.eti", "nested/a.bin"]); - let transaction = DownloadOwnershipTransaction::prepare(state.path(), &manifest) - .await - .expect("transaction should prepare"); + let game_root = confined_root(&manifest).await; + let transaction = + DownloadOwnershipTransaction::prepare(state.path(), &manifest, &game_root) + .await + .expect("transaction should prepare"); transaction .journal_pending() .await @@ -705,10 +732,12 @@ mod tests { .await; let manifest = manifest(games.path(), &["keep.eti", "new.eti"]); - let transaction = DownloadOwnershipTransaction::prepare(state.path(), &manifest) - .await - .expect("transaction should prepare"); - super::super::version_ini::begin_version_ini_transaction(&root) + let confined_root = confined_root(&manifest).await; + let transaction = + DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root) + .await + .expect("transaction should prepare"); + super::super::version_ini::begin_version_ini_transaction(&confined_root) .await .expect("sentinel should park"); transaction @@ -760,10 +789,12 @@ mod tests { let root = games.game_root(); write_file(&root.join(VERSION_INI), b"20240101"); let manifest = manifest(games.path(), &["archive.eti"]); - let _transaction = DownloadOwnershipTransaction::prepare(state.path(), &manifest) - .await - .expect("baseline ownership should be durable"); - super::super::version_ini::begin_version_ini_transaction(&root) + let confined_root = confined_root(&manifest).await; + let _transaction = + DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root) + .await + .expect("baseline ownership should be durable"); + super::super::version_ini::begin_version_ini_transaction(&confined_root) .await .expect("sentinel should park"); recover_incomplete_download(&root, state.path(), "game") @@ -864,7 +895,7 @@ mod tests { } #[tokio::test] - async fn published_record_is_committed_even_if_parent_sync_reports_failure() { + async fn published_record_reports_post_rename_sync_uncertainty() { let games = TempDir::new("lanspread-ownership-publish-games"); let state = TempDir::new("lanspread-ownership-publish-state"); let record = DownloadOwnershipRecord { @@ -877,12 +908,16 @@ mod tests { let record_path = download_ownership_path(state.path(), "game"); let tmp_path = download_ownership_tmp_path(state.path(), "game"); - write_record_with_parent_sync(&record_path, &tmp_path, &record, |_| { + let publication = write_record_with_parent_sync(&record_path, &tmp_path, &record, |_| { Err(std::io::Error::other("injected parent sync failure")) }) .await - .expect("post-publication sync failure must not look unpublished"); + .expect("post-publication sync failure must remain phase-aware"); + assert!(matches!( + publication, + OwnershipJournalPublication::NeedsRecovery(_) + )); assert_eq!(read_valid_record(state.path(), games.path()).await, record); } @@ -895,12 +930,11 @@ mod tests { write_file(&root.join("Archive.eti"), b"old"); seed_record(state.path(), games.path(), &["Archive.eti"], None).await; - let error = DownloadOwnershipTransaction::prepare( - state.path(), - &manifest(games.path(), &["archive.eti"]), - ) - .await - .expect_err("case-only ownership changes must fail closed"); + let manifest = manifest(games.path(), &["archive.eti"]); + let confined_root = confined_root(&manifest).await; + let error = DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root) + .await + .expect_err("case-only ownership changes must fail closed"); assert!(error.to_string().contains("portable filesystem alias")); assert!(root.join(VERSION_INI).is_file()); diff --git a/crates/lanspread-peer/src/download/planning.rs b/crates/lanspread-peer/src/download/planning.rs index c378b19..bfeabdd 100644 --- a/crates/lanspread-peer/src/download/planning.rs +++ b/crates/lanspread-peer/src/download/planning.rs @@ -1,13 +1,13 @@ use std::{collections::HashMap, net::SocketAddr}; -use lanspread_db::db::GameFileDescription; - +use super::manifest::{ValidatedDownloadEntry, ValidatedDownloadPath}; use crate::config::CHUNK_SIZE; /// Represents a chunk of a file to be downloaded. #[derive(Debug, Clone)] pub(super) struct DownloadChunk { - pub(super) relative_path: String, + pub(super) request_path: String, + pub(super) destination: ValidatedDownloadPath, pub(super) offset: u64, pub(super) length: u64, pub(super) retry_count: usize, @@ -28,33 +28,6 @@ pub(super) struct ChunkDownloadResult { pub(super) peer_addr: SocketAddr, } -/// Extracts the root `version.ini` descriptor while keeping every descriptor in -/// the transfer list. The chunk writer diverts the sentinel bytes into memory. -pub(super) fn extract_version_descriptor( - game_id: &str, - game_file_descs: Vec, -) -> eyre::Result<(GameFileDescription, Vec)> { - let mut version_descs = Vec::new(); - let mut transfer_descs = Vec::new(); - - for desc in game_file_descs { - if desc.is_version_ini() { - version_descs.push(desc.clone()); - } - transfer_descs.push(desc); - } - - if version_descs.len() != 1 { - eyre::bail!( - "expected exactly one root-level version.ini sentinel for {game_id}, found {}", - version_descs.len() - ); - } - - let version_desc = version_descs.remove(0); - Ok((version_desc, transfer_descs)) -} - /// Resolves which peers have a specific file. pub(super) fn resolve_file_peers<'a>( relative_path: &str, @@ -73,7 +46,7 @@ pub(super) fn resolve_file_peers<'a>( /// Builds download plans distributing files across peers. pub(super) fn build_peer_plans( peers: &[SocketAddr], - file_descs: &[GameFileDescription], + file_descs: &[ValidatedDownloadEntry], file_peer_map: &HashMap>, ) -> HashMap { let mut plans: HashMap = HashMap::new(); @@ -84,9 +57,9 @@ pub(super) fn build_peer_plans( let mut planned_bytes: HashMap = HashMap::new(); let mut tie_breaker = 0usize; - for desc in file_descs.iter().filter(|d| !d.is_dir) { - let size = desc.file_size(); - let eligible_peers = resolve_file_peers(&desc.relative_path, file_peer_map, peers); + for desc in file_descs.iter().filter(|entry| !entry.is_dir()) { + let size = desc.size(); + let eligible_peers = resolve_file_peers(desc.protocol_path(), file_peer_map, peers); if eligible_peers.is_empty() { continue; } @@ -95,7 +68,8 @@ pub(super) fn build_peer_plans( let peer = select_least_loaded_peer(eligible_peers, &planned_bytes, &mut tie_breaker); *planned_bytes.entry(peer).or_default() += 1; plans.entry(peer).or_default().chunks.push(DownloadChunk { - relative_path: desc.relative_path.clone(), + request_path: desc.protocol_path().to_owned(), + destination: desc.destination().clone(), offset: 0, length: 0, retry_count: 0, @@ -110,7 +84,8 @@ pub(super) fn build_peer_plans( let peer = select_least_loaded_peer(eligible_peers, &planned_bytes, &mut tie_breaker); *planned_bytes.entry(peer).or_default() += length; plans.entry(peer).or_default().chunks.push(DownloadChunk { - relative_path: desc.relative_path.clone(), + request_path: desc.protocol_path().to_owned(), + destination: desc.destination().clone(), offset, length, retry_count: 0, @@ -148,9 +123,13 @@ fn select_least_loaded_peer( #[cfg(test)] mod tests { - use lanspread_db::db::GameFileDescription; - use super::*; + + fn file(protocol_path: &str, size: u64) -> ValidatedDownloadEntry { + let canonical_path = protocol_path.strip_prefix("game/").unwrap_or(protocol_path); + ValidatedDownloadEntry::test_file(protocol_path, canonical_path, size) + } + fn loopback_addr(port: u16) -> SocketAddr { SocketAddr::from(([127, 0, 0, 1], port)) } @@ -161,12 +140,7 @@ mod tests { let file_size = CHUNK_SIZE * 2 + CHUNK_SIZE / 4; let mut file_peer_map = HashMap::new(); file_peer_map.insert("game/file.dat".to_string(), peers.clone()); - let file_descs = vec![GameFileDescription { - game_id: "test".to_string(), - relative_path: "game/file.dat".to_string(), - is_dir: false, - size: file_size, - }]; + let file_descs = vec![file("game/file.dat", file_size)]; let plans = build_peer_plans(&peers, &file_descs, &file_peer_map); let mut chunks: Vec<_> = plans.values().flat_map(|plan| plan.chunks.iter()).collect(); @@ -194,20 +168,7 @@ mod tests { let mut file_peer_map = HashMap::new(); file_peer_map.insert("game/version.ini".to_string(), peers.clone()); file_peer_map.insert(large_file.to_string(), peers.clone()); - let file_descs = vec![ - GameFileDescription { - game_id: "game".to_string(), - relative_path: "game/version.ini".to_string(), - is_dir: false, - size: 9, - }, - GameFileDescription { - game_id: "game".to_string(), - relative_path: large_file.to_string(), - is_dir: false, - size: file_size, - }, - ]; + let file_descs = vec![file("game/version.ini", 9), file(large_file, file_size)]; let plans = build_peer_plans(&peers, &file_descs, &file_peer_map); let mut chunk_counts = HashMap::new(); @@ -215,7 +176,7 @@ mod tests { for (peer, plan) in plans { for chunk in plan.chunks { - if chunk.relative_path == large_file { + if chunk.request_path == large_file { *chunk_counts.entry(peer).or_insert(0usize) += 1; *byte_counts.entry(peer).or_insert(0u64) += chunk.length; } @@ -250,18 +211,8 @@ mod tests { file_peer_map.insert("exclusive.bin".to_string(), vec![exclusive]); let file_descs = vec![ - GameFileDescription { - game_id: "test".to_string(), - relative_path: "shared.bin".to_string(), - is_dir: false, - size: CHUNK_SIZE * 2, - }, - GameFileDescription { - game_id: "test".to_string(), - relative_path: "exclusive.bin".to_string(), - is_dir: false, - size: CHUNK_SIZE, - }, + file("shared.bin", CHUNK_SIZE * 2), + file("exclusive.bin", CHUNK_SIZE), ]; let plans = build_peer_plans(&peers, &file_descs, &file_peer_map); @@ -272,13 +223,13 @@ mod tests { exclusive_plan .chunks .iter() - .all(|chunk| chunk.relative_path == "exclusive.bin"), + .all(|chunk| chunk.request_path == "exclusive.bin"), "exclusive peer should only receive exclusive.bin chunks" ); for (peer, plan) in plans { for chunk in plan.chunks { - match chunk.relative_path.as_str() { + match chunk.request_path.as_str() { "exclusive.bin" => assert_eq!( peer, exclusive, "exclusive.bin chunks should only be assigned to the exclusive peer" @@ -292,57 +243,4 @@ mod tests { } } } - - #[test] - fn version_descriptor_extraction_keeps_nested_decoy_in_transfer_list() { - let nested_decoy = vec![ - GameFileDescription { - game_id: "game".to_string(), - relative_path: "game/version.ini".to_string(), - is_dir: false, - size: 8, - }, - GameFileDescription { - game_id: "game".to_string(), - relative_path: "game/local/version.ini".to_string(), - is_dir: false, - size: 8, - }, - ]; - - let (version, transfer) = - extract_version_descriptor("game", nested_decoy).expect("only one root sentinel"); - assert_eq!(version.relative_path, "game/version.ini"); - assert_eq!(transfer.len(), 2); - } - - #[test] - fn version_descriptor_extraction_requires_a_root_version_ini() { - let missing = vec![GameFileDescription { - game_id: "game".to_string(), - relative_path: "game/archive.eti".to_string(), - is_dir: false, - size: 1, - }]; - assert!(extract_version_descriptor("game", missing).is_err()); - } - - #[test] - fn version_descriptor_extraction_rejects_duplicate_root_version_ini() { - let multiple = vec![ - GameFileDescription { - game_id: "game".to_string(), - relative_path: "game/version.ini".to_string(), - is_dir: false, - size: 8, - }, - GameFileDescription { - game_id: "game".to_string(), - relative_path: "game/version.ini".to_string(), - is_dir: false, - size: 8, - }, - ]; - assert!(extract_version_descriptor("game", multiple).is_err()); - } } diff --git a/crates/lanspread-peer/src/download/retry.rs b/crates/lanspread-peer/src/download/retry.rs index 814e70f..1432eb1 100644 --- a/crates/lanspread-peer/src/download/retry.rs +++ b/crates/lanspread-peer/src/download/retry.rs @@ -1,7 +1,6 @@ use std::{ collections::{HashMap, VecDeque}, net::SocketAddr, - path::Path, sync::Arc, }; @@ -9,6 +8,7 @@ use futures::{StreamExt, stream::FuturesUnordered}; use tokio_util::sync::CancellationToken; use super::{ + confined_fs::ConfinedGameRoot, planning::{ChunkDownloadResult, DownloadChunk, PeerDownloadPlan, resolve_file_peers}, progress::DownloadProgressTracker, transport::download_from_peer, @@ -55,7 +55,7 @@ struct RetryAttempt { pub(super) struct RetryContext<'a> { pub(super) peers: &'a [SocketAddr], - pub(super) base_dir: &'a Path, + pub(super) game_root: &'a ConfinedGameRoot, pub(super) game_id: &'a str, pub(super) file_peer_map: &'a HashMap>, pub(super) cancel_token: &'a CancellationToken, @@ -72,14 +72,14 @@ fn plan_retry_batch( let mut retry_plans: HashMap = HashMap::new(); while let Some(mut chunk) = queue.pop_front() { - let eligible_peers = resolve_file_peers(&chunk.relative_path, file_peer_map, peers); + let eligible_peers = resolve_file_peers(&chunk.request_path, file_peer_map, peers); if chunk.retry_count >= MAX_RETRY_COUNT { final_results.push(ChunkDownloadResult { chunk: chunk.clone(), result: Err(eyre::eyre!( "Retry budget exhausted for chunk: {}", - chunk.relative_path + chunk.request_path )), peer_addr: fallback_peer_addr(eligible_peers, chunk.last_peer), }); @@ -91,7 +91,7 @@ fn plan_retry_batch( chunk: chunk.clone(), result: Err(eyre::eyre!( "No peers available to retry chunk: {}", - chunk.relative_path + chunk.request_path )), peer_addr: fallback_peer_addr(eligible_peers, chunk.last_peer), }); @@ -113,7 +113,7 @@ async fn run_retry_batch( for (peer_addr, plan) in retry_plans { let retry_chunks = plan.chunks.clone(); - let base_dir = ctx.base_dir.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(); @@ -124,7 +124,7 @@ async fn run_retry_batch( peer_addr, &game_id, plan, - base_dir, + game_root, &cancel_token, version_buffer, progress_tracker, @@ -174,7 +174,7 @@ fn handle_retry_chunk_result( chunk.last_peer = Some(peer_addr); if chunk.retry_count >= MAX_RETRY_COUNT { - let context = format!("Retry budget exhausted for chunk: {}", chunk.relative_path); + let context = format!("Retry budget exhausted for chunk: {}", chunk.request_path); final_results.push(ChunkDownloadResult { chunk, result: Err(err.wrap_err(context)), @@ -205,7 +205,7 @@ fn handle_retry_attempt_error( chunk: chunk.clone(), result: Err(eyre::eyre!( "Retry budget exhausted for chunk after connection failure: {}: {error}", - chunk.relative_path + chunk.request_path )), peer_addr, }); diff --git a/crates/lanspread-peer/src/download/storage.rs b/crates/lanspread-peer/src/download/storage.rs index 7e883ce..6792e8b 100644 --- a/crates/lanspread-peer/src/download/storage.rs +++ b/crates/lanspread-peer/src/download/storage.rs @@ -1,90 +1,23 @@ -use std::{cmp::Reverse, collections::BTreeSet, path::PathBuf}; - -use tokio::fs::OpenOptions; - -use super::manifest::ValidatedDownloadManifest; +use super::{confined_fs::ConfinedGameRoot, manifest::ValidatedDownloadManifest}; /// Prepares storage for game files by creating directories and pre-allocating files. -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()); - - if entry.is_dir() { - tokio::fs::create_dir_all(&validated_path).await?; - } else { - if let Some(parent) = validated_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - let file = OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&validated_path) - .await?; - - let size = entry.size(); - file.set_len(size).await?; - log::debug!( - "Prepared file {} with {} bytes", - entry.canonical_path(), - size - ); - } - } - Ok(()) +pub(super) async fn prepare_game_storage( + manifest: &ValidatedDownloadManifest, + game_root: &ConfinedGameRoot, +) -> eyre::Result<()> { + game_root + .prepare_entries(manifest.transfer_entries().cloned().collect()) + .await } /// Makes payload bytes and directory entries durable before the sentinel commit. -pub(super) async fn sync_game_storage(manifest: &ValidatedDownloadManifest) -> eyre::Result<()> { - let mut directories = BTreeSet::new(); - directories.insert(manifest.game_root().to_path_buf()); - - for entry in manifest.transfer_entries() { - let path = manifest.game_root().join(entry.canonical_path()); - if entry.is_dir() { - directories.insert(path); - continue; - } - - OpenOptions::new() - .read(true) - .write(true) - .open(&path) - .await? - .sync_all() - .await?; - - let mut parent = path.parent(); - while let Some(path) = parent { - if !path.starts_with(manifest.game_root()) { - break; - } - directories.insert(path.to_path_buf()); - if path == manifest.game_root() { - break; - } - parent = path.parent(); - } - } - - let mut directories = directories.into_iter().collect::>(); - directories.sort_by_key(|path| Reverse(path.components().count())); - for directory in directories { - sync_directory(&directory).await?; - } - Ok(()) -} - -#[cfg(unix)] -async fn sync_directory(path: &std::path::Path) -> eyre::Result<()> { - tokio::fs::File::open(path).await?.sync_all().await?; - Ok(()) -} - -#[cfg(not(unix))] -async fn sync_directory(_path: &std::path::Path) -> eyre::Result<()> { - Ok(()) +pub(super) async fn sync_game_storage( + manifest: &ValidatedDownloadManifest, + game_root: &ConfinedGameRoot, +) -> eyre::Result<()> { + game_root + .sync_entries(manifest.transfer_entries().cloned().collect()) + .await } #[cfg(test)] @@ -111,7 +44,10 @@ mod tests { ) .expect("manifest should validate"); - prepare_game_storage(&manifest) + let game_root = ConfinedGameRoot::open_or_create(temp.path(), "game") + .await + .expect("confined game root should open"); + prepare_game_storage(&manifest, &game_root) .await .expect("storage preparation should succeed"); diff --git a/crates/lanspread-peer/src/download/transport.rs b/crates/lanspread-peer/src/download/transport.rs index 4ac06ea..9dd7d9f 100644 --- a/crates/lanspread-peer/src/download/transport.rs +++ b/crates/lanspread-peer/src/download/transport.rs @@ -1,31 +1,20 @@ -use std::{ - collections::VecDeque, - net::SocketAddr, - path::{Path, PathBuf}, - sync::Arc, -}; +use std::{collections::VecDeque, net::SocketAddr, sync::Arc}; use futures::{SinkExt, StreamExt, stream::FuturesUnordered}; use s2n_quic::{Connection, stream::ReceiveStream}; -use tokio::{ - fs::OpenOptions, - io::{AsyncSeekExt, AsyncWriteExt}, -}; +use tokio::io::{AsyncSeekExt, AsyncWriteExt}; use tokio_util::{ codec::{FramedWrite, LengthDelimitedCodec}, sync::CancellationToken, }; use super::{ + confined_fs::ConfinedGameRoot, planning::{ChunkDownloadResult, DownloadChunk, PeerDownloadPlan}, progress::DownloadProgressTracker, version_ini::VersionIniBuffer, }; -use crate::{ - config::PEER_DOWNLOAD_STREAM_WINDOW, - network::connect_to_peer, - path_validation::validate_game_file_path, -}; +use crate::{config::PEER_DOWNLOAD_STREAM_WINDOW, network::connect_to_peer}; fn ensure_download_not_cancelled( cancel_token: &CancellationToken, @@ -92,7 +81,7 @@ async fn open_chunk_stream( let request = Request::GetGameFileChunk { game_id: game_id.to_string(), - relative_path: chunk.relative_path.clone(), + relative_path: chunk.request_path.clone(), offset: chunk.offset, length: chunk.length, }; @@ -106,34 +95,23 @@ async fn open_chunk_stream( async fn receive_chunk( peer_addr: SocketAddr, mut rx: ReceiveStream, - base_dir: &Path, + game_root: &ConfinedGameRoot, chunk: &DownloadChunk, version_buffer: Option>, progress_tracker: Arc, ) -> eyre::Result<()> { if let Some(buffer) = version_buffer - && buffer.matches(&chunk.relative_path) + && buffer.matches(&chunk.request_path) { return download_version_ini_chunk(peer_addr, rx, chunk, &buffer, progress_tracker).await; } - // Validate the path to prevent directory traversal - let validated_path = validate_game_file_path(base_dir, &chunk.relative_path)?; - let mut file = OpenOptions::new() - .create(true) - .write(true) - .truncate(false) - .open(&validated_path) - .await?; - if chunk.length == 0 && chunk.offset == 0 { - // A manifest-declared empty file replaces any existing partial data. - file.set_len(0).await?; - } + let mut file = tokio::fs::File::from_std(game_root.open_chunk_file(&chunk.destination).await?); file.seek(std::io::SeekFrom::Start(chunk.offset)).await?; let mut receive_budget = ReceiveBudget::new(chunk.length); let mut progress = - progress_tracker.track_chunk(peer_addr, &chunk.relative_path, chunk.offset, chunk.length); + progress_tracker.track_chunk(peer_addr, &chunk.request_path, chunk.offset, chunk.length); while let Some(bytes) = rx.receive().await? { receive_budget.accept(bytes.len())?; @@ -145,14 +123,14 @@ async fn receive_chunk( file.flush().await?; // Verify file integrity by checking the file size - verify_chunk_integrity(&validated_path, chunk.offset, chunk.length).await?; + verify_chunk_integrity(&file, chunk.offset, chunk.length).await?; Ok(()) } async fn receive_chunk_result( peer_addr: SocketAddr, - base_dir: PathBuf, + game_root: ConfinedGameRoot, chunk: DownloadChunk, rx: ReceiveStream, version_buffer: Option>, @@ -161,7 +139,7 @@ async fn receive_chunk_result( let result = receive_chunk( peer_addr, rx, - &base_dir, + &game_root, &chunk, version_buffer, progress_tracker, @@ -184,7 +162,7 @@ async fn download_version_ini_chunk( 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); + progress_tracker.track_chunk(peer_addr, &chunk.request_path, chunk.offset, chunk.length); while let Some(bytes) = rx.receive().await? { receive_budget.accept(bytes.len())?; progress.record_bytes(bytes.len()); @@ -197,7 +175,7 @@ async fn download_version_ini_chunk( /// Verifies that a chunk was written correctly. async fn verify_chunk_integrity( - file_path: &Path, + file: &tokio::fs::File, offset: u64, expected_length: u64, ) -> eyre::Result<()> { @@ -205,7 +183,7 @@ async fn verify_chunk_integrity( return Ok(()); // Skip verification for whole files or zero-length chunks } - let metadata = tokio::fs::metadata(file_path).await?; + let metadata = file.metadata().await?; let file_size = metadata.len(); if file_size < offset + expected_length { @@ -247,7 +225,7 @@ fn failed_plan_results( struct ChunkPlanContext<'a> { peer_addr: SocketAddr, game_id: &'a str, - base_dir: &'a Path, + game_root: &'a ConfinedGameRoot, cancel_token: &'a CancellationToken, version_buffer: Option>, progress_tracker: Arc, @@ -262,7 +240,7 @@ async fn download_chunk_plan( let mut in_flight = FuturesUnordered::new(); let mut results = Vec::new(); let window = PEER_DOWNLOAD_STREAM_WINDOW.max(1); - let base_dir = ctx.base_dir.to_path_buf(); + let game_root = ctx.game_root.clone(); while !pending.is_empty() || !in_flight.is_empty() { while in_flight.len() < window { @@ -273,7 +251,7 @@ async fn download_chunk_plan( log::info!( "Downloading chunk {} (offset {}, length {}) from {}", - chunk.relative_path, + chunk.request_path, chunk.offset, chunk.length, ctx.peer_addr @@ -283,7 +261,7 @@ async fn download_chunk_plan( Ok(rx) => { in_flight.push(receive_chunk_result( ctx.peer_addr, - base_dir.clone(), + game_root.clone(), chunk, rx, ctx.version_buffer.clone(), @@ -326,7 +304,7 @@ pub(super) async fn download_from_peer( peer_addr: SocketAddr, game_id: &str, plan: PeerDownloadPlan, - games_folder: PathBuf, + game_root: ConfinedGameRoot, cancel_token: &CancellationToken, version_buffer: Option>, progress_tracker: Arc, @@ -351,11 +329,10 @@ pub(super) async fn download_from_peer( return Ok(failed_plan_results(plan, peer_addr, err)); } - let base_dir = games_folder; let chunk_ctx = ChunkPlanContext { peer_addr, game_id, - base_dir: &base_dir, + game_root: &game_root, cancel_token, version_buffer, progress_tracker, diff --git a/crates/lanspread-peer/src/download/version_ini.rs b/crates/lanspread-peer/src/download/version_ini.rs index ecca87c..0d7042c 100644 --- a/crates/lanspread-peer/src/download/version_ini.rs +++ b/crates/lanspread-peer/src/download/version_ini.rs @@ -1,8 +1,6 @@ -use std::path::Path; - -use lanspread_db::db::GameFileDescription; use tokio::{io::AsyncWriteExt, sync::Mutex}; +use super::confined_fs::ConfinedGameRoot; use crate::game_paths::{VERSION_DISCARDED_FILE, VERSION_INI, VERSION_TMP_FILE}; pub(super) enum VersionIniCommit { @@ -19,13 +17,10 @@ pub(super) struct VersionIniBuffer { } impl VersionIniBuffer { - pub(super) fn new(desc: &GameFileDescription) -> eyre::Result { - if desc.is_dir { - eyre::bail!("version.ini sentinel cannot be a directory"); - } - let size = usize::try_from(desc.size)?; + pub(super) fn new(relative_path: &str, size: u64) -> eyre::Result { + let size = usize::try_from(size)?; Ok(Self { - relative_path: desc.relative_path.clone(), + relative_path: relative_path.to_owned(), bytes: Mutex::new(vec![0; size]), }) } @@ -55,163 +50,137 @@ impl VersionIniBuffer { } } -pub(super) async fn begin_version_ini_transaction(game_root: &Path) -> eyre::Result<()> { - tokio::fs::create_dir_all(game_root).await?; - sync_parent_dir(game_root)?; - remove_file_if_exists(&game_root.join(VERSION_TMP_FILE)).await?; - remove_file_if_exists(&game_root.join(VERSION_DISCARDED_FILE)).await?; - sync_game_root(game_root)?; +pub(super) async fn begin_version_ini_transaction( + game_root: &ConfinedGameRoot, +) -> eyre::Result<()> { + game_root + .remove_root_file_if_exists(VERSION_TMP_FILE) + .await?; + game_root + .remove_root_file_if_exists(VERSION_DISCARDED_FILE) + .await?; - let version_path = game_root.join(VERSION_INI); - if tokio::fs::metadata(&version_path) - .await - .is_ok_and(|metadata| metadata.is_file()) - { - tokio::fs::rename(version_path, game_root.join(VERSION_DISCARDED_FILE)).await?; - sync_parent_dir(&game_root.join(VERSION_DISCARDED_FILE))?; + if game_root.root_regular_file_exists(VERSION_INI).await? { + game_root + .rename_root_file(VERSION_INI, VERSION_DISCARDED_FILE) + .await?; + game_root.sync_root().await?; } Ok(()) } #[cfg(test)] -pub(super) async fn rollback_version_ini_transaction(game_root: &Path) { +pub(super) async fn rollback_version_ini_transaction(game_root: &ConfinedGameRoot) { if let Err(err) = discard_version_ini_transaction(game_root).await { log::warn!( "Failed to discard version.ini transaction in {}: {err}", - game_root.display() + game_root.display_path().display() ); } } /// Restores the old sentinel after a crash or failure before ownership journaling. pub(super) async fn restore_unjournaled_version_ini_transaction( - game_root: &Path, + game_root: &ConfinedGameRoot, ) -> eyre::Result<()> { - remove_file_if_exists(&game_root.join(VERSION_TMP_FILE)).await?; - let version_path = game_root.join(VERSION_INI); - let discarded_path = game_root.join(VERSION_DISCARDED_FILE); - if path_is_regular_file(&version_path).await? { - remove_file_if_exists(&discarded_path).await?; - sync_game_root(game_root)?; + game_root + .remove_root_file_if_exists(VERSION_TMP_FILE) + .await?; + if game_root.root_regular_file_exists(VERSION_INI).await? { + game_root + .remove_root_file_if_exists(VERSION_DISCARDED_FILE) + .await?; return Ok(()); } - if path_is_regular_file(&discarded_path).await? { - tokio::fs::rename(&discarded_path, &version_path).await?; - sync_parent_dir(&version_path)?; + if game_root + .root_regular_file_exists(VERSION_DISCARDED_FILE) + .await? + { + game_root + .rename_root_file(VERSION_DISCARDED_FILE, VERSION_INI) + .await?; + game_root.sync_root().await?; } Ok(()) } /// Removes all sentinel scratch after an aborted journaled download. -pub(super) async fn discard_version_ini_transaction(game_root: &Path) -> eyre::Result<()> { - remove_file_if_exists(&game_root.join(VERSION_TMP_FILE)).await?; - remove_file_if_exists(&game_root.join(VERSION_DISCARDED_FILE)).await?; - sync_game_root_if_exists(game_root)?; +pub(super) async fn discard_version_ini_transaction( + game_root: &ConfinedGameRoot, +) -> eyre::Result<()> { + game_root + .remove_root_file_if_exists(VERSION_TMP_FILE) + .await?; + game_root + .remove_root_file_if_exists(VERSION_DISCARDED_FILE) + .await?; + game_root.sync_root().await?; Ok(()) } /// Sweeps scratch left after a committed sentinel was recovered. -pub(super) async fn finish_recovered_version_ini_transaction(game_root: &Path) -> eyre::Result<()> { +pub(super) async fn finish_recovered_version_ini_transaction( + game_root: &ConfinedGameRoot, +) -> eyre::Result<()> { discard_version_ini_transaction(game_root).await } pub(super) async fn commit_version_ini_buffer( - game_root: &Path, + game_root: &ConfinedGameRoot, buffer: &VersionIniBuffer, ) -> eyre::Result { - commit_version_ini_buffer_with_sync(game_root, buffer, sync_game_root).await + commit_version_ini_buffer_with_sync(game_root, buffer, None).await } async fn commit_version_ini_buffer_with_sync( - game_root: &Path, + game_root: &ConfinedGameRoot, buffer: &VersionIniBuffer, - mut sync_root: impl FnMut(&Path) -> std::io::Result<()>, + injected_sync_error: Option, ) -> eyre::Result { - let tmp_path = game_root.join(VERSION_TMP_FILE); - let version_path = game_root.join(VERSION_INI); let bytes = buffer.snapshot().await; - let mut file = tokio::fs::File::create(&tmp_path).await?; + let mut file = + tokio::fs::File::from_std(game_root.create_new_root_file(VERSION_TMP_FILE).await?); file.write_all(&bytes).await?; file.sync_all().await?; drop(file); - tokio::fs::rename(&tmp_path, &version_path).await?; - if let Err(error) = sync_root(game_root) { + game_root + .rename_root_file(VERSION_TMP_FILE, VERSION_INI) + .await?; + if let Some(error) = injected_sync_error { return Ok(VersionIniCommit::NeedsRecovery(error)); } - if let Err(error) = remove_file_if_exists(&game_root.join(VERSION_DISCARDED_FILE)).await { + if let Err(error) = game_root.sync_root().await { + return Ok(VersionIniCommit::NeedsRecovery(error)); + } + if let Err(error) = game_root + .remove_root_file_if_exists(VERSION_DISCARDED_FILE) + .await + { log::warn!( "Committed {} but failed to sweep the parked sentinel: {error}", - version_path.display() - ); - } - if let Err(error) = sync_root(game_root) { - log::warn!( - "Committed {} but failed to sync discarded-sentinel cleanup: {error}", - version_path.display() + game_root.display_path().join(VERSION_INI).display() ); } Ok(VersionIniCommit::Durable) } -async fn path_is_regular_file(path: &Path) -> eyre::Result { - match tokio::fs::symlink_metadata(path).await { - Ok(metadata) => Ok(metadata.is_file() && !metadata.file_type().is_symlink()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(error.into()), - } -} - -#[cfg(unix)] -fn sync_parent_dir(path: &Path) -> std::io::Result<()> { - if let Some(parent) = path.parent() { - std::fs::File::open(parent)?.sync_all()?; - } - Ok(()) -} - -#[cfg(not(unix))] -fn sync_parent_dir(_path: &Path) -> std::io::Result<()> { - Ok(()) -} - -fn sync_game_root(game_root: &Path) -> std::io::Result<()> { - sync_parent_dir(&game_root.join(VERSION_INI)) -} - -fn sync_game_root_if_exists(game_root: &Path) -> std::io::Result<()> { - match sync_game_root(game_root) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error), - } -} - -async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> { - match tokio::fs::remove_file(path).await { - Ok(()) => Ok(()), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(err) => Err(err.into()), - } -} - #[cfg(test)] mod tests { - use lanspread_db::db::GameFileDescription; - use super::*; use crate::test_support::TempDir; + async fn open_game_root(temp: &TempDir) -> ConfinedGameRoot { + ConfinedGameRoot::open_or_create(temp.path(), "game") + .await + .expect("confined game root should open") + } + #[tokio::test] async fn version_ini_buffer_accepts_out_of_order_chunks() { - let desc = GameFileDescription { - game_id: "game".to_string(), - relative_path: "game/version.ini".to_string(), - is_dir: false, - size: 8, - }; - let buffer = VersionIniBuffer::new(&desc).expect("buffer should be created"); + let buffer = + VersionIniBuffer::new("game/version.ini", 8).expect("buffer should be created"); buffer .write_at(4, b"0101") @@ -228,21 +197,13 @@ mod tests { #[tokio::test] async fn commit_version_ini_writes_sentinel_last_and_sweeps_discarded() { let temp = TempDir::new("lanspread-download"); - let game_root = temp.path().join("game"); - tokio::fs::create_dir_all(&game_root) - .await - .expect("game root should be created"); - tokio::fs::write(game_root.join(".version.ini.discarded"), b"old") + let game_root = open_game_root(&temp).await; + tokio::fs::write(temp.game_root().join(".version.ini.discarded"), b"old") .await .expect("discarded sentinel should be written"); - let desc = GameFileDescription { - game_id: "game".to_string(), - relative_path: "game/version.ini".to_string(), - is_dir: false, - size: 8, - }; - let buffer = VersionIniBuffer::new(&desc).expect("buffer should be created"); + let buffer = + VersionIniBuffer::new("game/version.ini", 8).expect("buffer should be created"); buffer .write_at(0, b"20250101") .await @@ -253,62 +214,53 @@ mod tests { .expect("version sentinel should commit"); assert_eq!( - std::fs::read(game_root.join("version.ini")).expect("version.ini should exist"), + std::fs::read(temp.game_root().join("version.ini")).expect("version.ini should exist"), b"20250101" ); - assert!(!game_root.join(".version.ini.tmp").exists()); - assert!(!game_root.join(".version.ini.discarded").exists()); + assert!(!temp.game_root().join(".version.ini.tmp").exists()); + assert!(!temp.game_root().join(".version.ini.discarded").exists()); } #[tokio::test] async fn landed_rename_with_failed_parent_sync_keeps_recovery_state() { let temp = TempDir::new("lanspread-version-durability"); - let game_root = temp.game_root(); - tokio::fs::create_dir_all(&game_root) - .await - .expect("game root should be created"); - tokio::fs::write(game_root.join(VERSION_DISCARDED_FILE), b"20240101") + let game_root = open_game_root(&temp).await; + tokio::fs::write(temp.game_root().join(VERSION_DISCARDED_FILE), b"20240101") .await .expect("old sentinel should be parked"); - let desc = GameFileDescription { - game_id: "game".to_string(), - relative_path: "game/version.ini".to_string(), - is_dir: false, - size: 8, - }; - let buffer = VersionIniBuffer::new(&desc).expect("buffer should be created"); + let buffer = + VersionIniBuffer::new("game/version.ini", 8).expect("buffer should be created"); buffer .write_at(0, b"20250101") .await .expect("sentinel bytes should be buffered"); - let outcome = commit_version_ini_buffer_with_sync(&game_root, &buffer, |_| { - Err(std::io::Error::other("injected directory sync failure")) - }) + let outcome = commit_version_ini_buffer_with_sync( + &game_root, + &buffer, + Some(std::io::Error::other("injected directory sync failure")), + ) .await .expect("the landed rename should be reported as recoverable"); assert!(matches!(outcome, VersionIniCommit::NeedsRecovery(_))); assert_eq!( - tokio::fs::read(game_root.join(VERSION_INI)) + tokio::fs::read(temp.game_root().join(VERSION_INI)) .await .expect("new sentinel should be visible"), b"20250101" ); - assert!(game_root.join(VERSION_DISCARDED_FILE).is_file()); + assert!(temp.game_root().join(VERSION_DISCARDED_FILE).is_file()); } #[tokio::test] async fn begin_version_ini_transaction_parks_existing_sentinel() { let temp = TempDir::new("lanspread-download"); - let game_root = temp.path().join("game"); - tokio::fs::create_dir_all(&game_root) - .await - .expect("game root should be created"); - tokio::fs::write(game_root.join("version.ini"), b"20240101") + let game_root = open_game_root(&temp).await; + tokio::fs::write(temp.game_root().join("version.ini"), b"20240101") .await .expect("version sentinel should be written"); - tokio::fs::write(game_root.join(".version.ini.tmp"), b"partial") + tokio::fs::write(temp.game_root().join(".version.ini.tmp"), b"partial") .await .expect("tmp sentinel should be written"); @@ -316,32 +268,51 @@ mod tests { .await .expect("transaction should begin"); - assert!(!game_root.join("version.ini").exists()); - assert!(!game_root.join(".version.ini.tmp").exists()); + assert!(!temp.game_root().join("version.ini").exists()); + assert!(!temp.game_root().join(".version.ini.tmp").exists()); assert_eq!( - std::fs::read(game_root.join(".version.ini.discarded")) + std::fs::read(temp.game_root().join(".version.ini.discarded")) .expect("discarded sentinel should exist"), b"20240101" ); } + #[cfg(unix)] + #[tokio::test] + async fn begin_can_inspect_and_park_read_only_sentinel() { + use std::os::unix::fs::PermissionsExt as _; + + let temp = TempDir::new("lanspread-read-only-version"); + let game_root = open_game_root(&temp).await; + let version_path = temp.game_root().join(VERSION_INI); + tokio::fs::write(&version_path, b"20240101") + .await + .expect("version sentinel should be written"); + std::fs::set_permissions(&version_path, std::fs::Permissions::from_mode(0o444)) + .expect("version sentinel should become read-only"); + + begin_version_ini_transaction(&game_root) + .await + .expect("read-only sentinel should park"); + + assert!(!version_path.exists()); + assert!(temp.game_root().join(VERSION_DISCARDED_FILE).is_file()); + } + #[tokio::test] async fn rollback_version_ini_transaction_sweeps_transients() { let temp = TempDir::new("lanspread-download"); - let game_root = temp.path().join("game"); - tokio::fs::create_dir_all(&game_root) - .await - .expect("game root should be created"); - tokio::fs::write(game_root.join(".version.ini.tmp"), b"partial") + let game_root = open_game_root(&temp).await; + tokio::fs::write(temp.game_root().join(".version.ini.tmp"), b"partial") .await .expect("tmp sentinel should be written"); - tokio::fs::write(game_root.join(".version.ini.discarded"), b"old") + tokio::fs::write(temp.game_root().join(".version.ini.discarded"), b"old") .await .expect("discarded sentinel should be written"); rollback_version_ini_transaction(&game_root).await; - assert!(!game_root.join(".version.ini.tmp").exists()); - assert!(!game_root.join(".version.ini.discarded").exists()); + assert!(!temp.game_root().join(".version.ini.tmp").exists()); + assert!(!temp.game_root().join(".version.ini.discarded").exists()); } } diff --git a/crates/lanspread-peer/src/install/transaction.rs b/crates/lanspread-peer/src/install/transaction.rs index 347325e..11a2d02 100644 --- a/crates/lanspread-peer/src/install/transaction.rs +++ b/crates/lanspread-peer/src/install/transaction.rs @@ -1215,7 +1215,7 @@ mod tests { for (id, intent_state, has_backup) in cases { let temp = TempDir::new("lanspread-install"); - let root = temp.game_root(); + let root = temp.path().join(id); write_file(&root.join("version.ini"), b"20250101"); write_file(&root.join(LOCAL_DIR).join("payload.txt"), LOCAL_PAYLOAD); if has_backup { diff --git a/organize/decision-tracking/PEER-AUTH-REFACTOR-DECISIONS.md b/organize/decision-tracking/PEER-AUTH-REFACTOR-DECISIONS.md index 1828321..2086d5c 100644 --- a/organize/decision-tracking/PEER-AUTH-REFACTOR-DECISIONS.md +++ b/organize/decision-tracking/PEER-AUTH-REFACTOR-DECISIONS.md @@ -140,25 +140,27 @@ Alternatives: **TL;DR:** Once an ownership-record temporary file has been synced and renamed, the application treats it as published. A subsequent parent-directory sync -failure is logged but is not reported as a pre-publication failure. +failure is returned as a distinct `NeedsRecovery` outcome: callers never roll +back as though publication failed, and a downloader stops before payload +mutation. Rolling back after the rename could restore the old `version.ini` beside a visible pending record. Recovery would then mistake that old sentinel for the -new download's commit point. Treating rename as publication keeps every -observable state unambiguous; the parent sync still runs to improve power-loss -durability. +new download's commit point. Continuing after a failed directory sync is also +unsafe: power loss could retain the previous baseline record and later restore +the old sentinel over mutated payload. The phase-aware result keeps every +observable state unambiguous and prevents both mistakes. Alternatives: -- Return a phase-aware error that forces every caller to distinguish failures - before and after rename. This is explicit, but spreads a subtle transaction - protocol across all ownership callers without changing their post-rename - action. +- Log the post-rename sync failure and continue. This avoids disrupting a + download for a rare filesystem error, but permits mutation without a durable + write-ahead record. - Try to rename the old record back after a sync failure. That adds another fallible mutation and can still leave either name visible after a crash. -- Treat every sync failure as fatal and leave the new record in place. This - sounds stricter, but callers could then perform the unsafe sentinel rollback - unless the error also carries publication state. +- Return one ordinary error for every failure. This is simpler, but callers + cannot tell whether restoring the old sentinel is safe after the canonical + record name became visible. ## 2026-08-09 — Reject cross-version portable path aliases @@ -222,3 +224,99 @@ Alternatives: - Introduce another dedicated phase-marker file. This is equally expressive, but adds a second persistent transaction artifact where an empty valid ledger already provides the needed proof. + +## 2026-08-09 — Treat the configured games directory as the capability root + +**TL;DR:** Canonicalize the user-selected games directory once, open the game as +one direct non-link child, and perform every download, sentinel, and ownership +mutation relative to that retained directory handle. The configured directory +itself is the trust anchor; existing links in its absolute parent path are not +re-walked and rejected. + +This matches the product setting: the user chooses one library directory, while +remote descriptions control only descendants of a known catalog game. Rejecting +the game root and every descendant link/reparse component closes the peer-driven +escape without redefining whether the configured library path may itself be a +platform alias or mounted location. + +Alternatives: + +- Reject every link in every absolute ancestor of the configured directory. This + is stricter, but rejects common user-selected paths and requires + platform-specific absolute-path walking outside the remote peer's authority. +- Keep one capability from application startup through every settings change. + This minimizes ambient path resolution, but considerably expands lifecycle and + settings coordination for no additional peer-controlled path component. +- Revalidate strings before each ordinary path operation. This is simpler, but + remains vulnerable to check-then-use swaps and reparse behavior. + +## 2026-08-09 — Retain one root handle and reopen each transfer file safely + +**TL;DR:** Keep one capability handle for the game root, but open each directory +component and final file without following links for preparation, each chunk, +durability checks, and cleanup. Do not retain a handle for every manifest file. + +A manifest may contain 100,000 entries. Retaining all file handles would make +descriptor exhaustion part of ordinary planning. Reopening from the stable root +keeps resource use bounded; each chunk writes and verifies through the exact +handle it opened, so a later path swap cannot redirect that write. + +Alternatives: + +- Retain every destination handle for the full download. This gives the + strongest object identity, but can exhaust process and system handle limits. +- Retain one handle per active chunk. This is feasible, but still needs the same + no-follow reopen walk and does not simplify preparation or recovery. +- Use absolute paths after initial validation. This uses fewer abstractions, but + reintroduces the link/reparse race the confinement phase exists to remove. + +## 2026-08-09 — Do not overstate Windows power-loss durability + +**TL;DR:** Sync payload and sentinel file handles on every platform and sync +directory handles where the safe Rust platform API supports it. On Windows, +retain the unambiguous process-crash recovery protocol but leave power-loss +durability as an explicit real-NTFS gate rather than claiming proof from Linux. + +Rust does not provide a portable guaranteed directory flush, and this crate +forbids unsafe code. Silently calling a Unix-only directory `sync_all` +equivalent would turn an unverified assumption into a false cross-platform +guarantee. The Phase 1 Windows gate therefore still needs a supported Windows +run covering reparse points, rename recovery, and actual filesystem behavior. + +Alternatives: + +- Add a small platform-specific safe wrapper crate around Windows directory + handles and `FlushFileBuffers`. This may establish stronger durability, but it + adds native code and still requires real NTFS failure evidence. +- Fail every Windows download because directory durability is not portable. This + is fail-closed but makes a supported product platform unusable. +- Treat successful file sync and rename as proven power-loss durability. This is + convenient, but is not evidence and directly violates the plan's reporting + boundary. + +## 2026-08-09 — Do not make hard-link identity part of remote confinement + +**TL;DR:** Reject links and reparse points that can redirect path resolution, +but do not reject an otherwise regular manifest target merely because it has +multiple hard links. The threat model excludes an attacker controlling the +victim filesystem, and exact manifest target paths already become download-owned +when a transaction starts. + +A hard link cannot be selected or created by a remote description outside the +validated game-relative namespace. A local user can make the same inode visible +under another name, but that is local filesystem manipulation rather than a +peer-controlled path escape. This choice inherits the documented consequence +that replacing an exact ambiguous manifest target may affect another local name +for those bytes. + +Alternatives: + +- Reject every existing destination whose link count exceeds one. This better + protects local aliases, but can block legitimate deduplicated or legacy game + trees and needs consistent evidence on every supported filesystem. +- Copy an existing multiply linked file to a private inode before mutation. This + preserves the other name, but silently consumes space and adds another + fallible pre-transfer mutation. +- Track inode identities in the ownership ledger. This can detect later + replacement, but makes persistent state platform-specific and still cannot + prevent a local actor from changing links concurrently.