fix(peer): confine download mutations to game root handles

Remote manifests were validated before mutation, but preparation, chunk writes,
sentinel transactions, and ownership recovery later reopened ambient paths. A
link or reparse-point swap between those steps could redirect a mutation outside
the validated game root.

Introduce a retained ConfinedGameRoot capability backed by cap-primitives. Carry
typed validated destinations into chunk plans, walk every component without
following links, and perform payload, sentinel, stale-file, abort, and recovery
mutations relative to the retained handle. File writes and verification use the
same opened handle, while final durability syncs payload files and unique parent
directories before committing version.ini.

Make ownership-record publication phase-aware as well. A directory-sync failure
after record rename now stops before payload mutation without performing an
unsafe old-sentinel rollback. Record the capability-root, bounded-handle,
hard-link, and unproven Windows durability tradeoffs in the decision log.

Test Plan:
- `just clippy` -- passed
- `just test` -- passed; 185 peer tests and the full workspace are green
- `just fmt` -- Rust, TOML, and Prettier completed; command remains nonzero on
  39 pre-existing rumdl issues outside this change
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-09 19:22:11 +02:00
parent 62cd9306bd
commit 691176e1d5
16 changed files with 1325 additions and 591 deletions
Generated
+96
View File
@@ -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"
+2
View File
@@ -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"
+11 -2
View File
@@ -159,8 +159,9 @@ Downloaded and installed are independent predicates:
- `downloaded` is true only when `<game_root>/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 `<game_root>/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.
+2
View File
@@ -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 }
@@ -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<ConfinedGameRootInner>,
}
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<Self> {
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<Option<Self>> {
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::<std::io::Error>()
.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<Self> {
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<ValidatedDownloadEntry>,
) -> 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<File> {
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<ValidatedDownloadEntry>,
) -> 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<ValidatedDownloadPath>,
) -> 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<bool> {
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::<std::io::Error>()
.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<File> {
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<File> {
let mut current = self.inner.game_root.try_clone()?;
for component in path.components() {
current = open_directory_component(&current, component, create)?;
}
Ok(current)
}
fn open_regular_file_blocking(
&self,
path: &ValidatedDownloadPath,
create: bool,
) -> eyre::Result<File> {
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<File> {
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<File> {
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(&current, 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<Option<(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(Some((current, component)));
}
let metadata = match fs::stat(&current, 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(&current, 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<File> {
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<File> {
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<File> {
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<File> {
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::<std::io::Error>().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());
}
}
+49 -21
View File
@@ -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<Self> {
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<Item = &str> {
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<ValidatedDownloadEntry>,
}
@@ -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<GameFileDescription> {
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<String> {
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<Vec<ValidatedDownloadEntry>> {
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::<Vec<_>>();
assert_eq!(paths, ["archive.eti", "version.ini"]);
let version_ini = manifest
@@ -1,5 +1,6 @@
//! Download pipeline for game files from peers.
mod confined_fs;
mod manifest;
mod orchestrator;
mod ownership;
@@ -1,13 +1,13 @@
use std::{collections::HashMap, net::SocketAddr, path::Path, sync::Arc};
use lanspread_db::db::GameFileDescription;
use tokio::sync::mpsc::UnboundedSender;
use tokio_util::sync::CancellationToken;
use super::{
manifest::ValidatedDownloadManifest,
ownership::DownloadOwnershipTransaction,
planning::{ChunkDownloadResult, DownloadChunk, build_peer_plans, extract_version_descriptor},
confined_fs::ConfinedGameRoot,
manifest::{ValidatedDownloadEntry, ValidatedDownloadManifest},
ownership::{DownloadOwnershipTransaction, OwnershipJournalPublication},
planning::{ChunkDownloadResult, DownloadChunk, build_peer_plans},
progress::{DownloadProgressTracker, sample_download_progress},
retry::{RetryContext, retry_failed_chunks},
storage::{prepare_game_storage, sync_game_storage},
@@ -33,7 +33,6 @@ pub(crate) async fn download_game_files(
cancel_token: CancellationToken,
) -> eyre::Result<()> {
let game_id = manifest.game_id().to_owned();
let games_folder = manifest.games_folder().to_path_buf();
if peers.is_empty() {
eyre::bail!("no peers available for game {game_id}");
}
@@ -42,17 +41,18 @@ pub(crate) async fn download_game_files(
eyre::bail!("download cancelled for game {game_id}");
}
let game_file_descs = manifest.protocol_descriptions();
let (version_desc, transfer_descs) = extract_version_descriptor(&game_id, game_file_descs)?;
let version_buffer = match VersionIniBuffer::new(&version_desc) {
Ok(buffer) => Arc::new(buffer),
Err(err) => return Err(err),
};
let game_root = manifest.game_root().to_path_buf();
let ownership = DownloadOwnershipTransaction::prepare(state_dir, &manifest).await?;
let version_entry = manifest.version_entry();
let version_buffer =
match VersionIniBuffer::new(version_entry.protocol_path(), version_entry.size()) {
Ok(buffer) => Arc::new(buffer),
Err(err) => return Err(err),
};
let confined_root = ConfinedGameRoot::open_or_create(manifest.games_folder(), &game_id).await?;
let ownership =
DownloadOwnershipTransaction::prepare(state_dir, &manifest, &confined_root).await?;
if let Err(error) = begin_version_ini_transaction(&game_root).await {
if let Err(restore_error) = restore_before_ownership_journal(&game_root).await {
if let Err(error) = begin_version_ini_transaction(&confined_root).await {
if let Err(restore_error) = restore_before_ownership_journal(&confined_root).await {
return Err(error.wrap_err(format!(
"sentinel parking failed and rollback also failed: {restore_error}"
)));
@@ -60,18 +60,29 @@ pub(crate) async fn download_game_files(
return Err(error);
}
if cancel_token.is_cancelled() {
restore_before_ownership_journal(&game_root).await?;
restore_before_ownership_journal(&confined_root).await?;
eyre::bail!("download cancelled for game {game_id}");
}
if let Err(error) = ownership.journal_pending().await {
if let Err(restore_error) = restore_before_ownership_journal(&game_root).await {
return Err(error.wrap_err(format!(
"ownership journal failed and sentinel restore also failed: {restore_error}"
)));
match ownership.journal_pending().await {
Ok(OwnershipJournalPublication::Durable) => {}
Ok(OwnershipJournalPublication::NeedsRecovery(error)) => {
// The pending record is visible, so restoring the old sentinel
// would make recovery mistake it for a landed new commit. Stop
// before payload mutation and leave the phase unambiguous.
return Err(eyre::eyre!(
"pending download ownership was renamed but its durability could not be established: {error}"
));
}
Err(error) => {
if let Err(restore_error) = restore_before_ownership_journal(&confined_root).await {
return Err(error.wrap_err(format!(
"ownership journal failed and sentinel restore also failed: {restore_error}"
)));
}
return Err(error);
}
return Err(error);
}
if let Err(err) = prepare_game_storage(&manifest).await {
if let Err(err) = prepare_game_storage(&manifest, &confined_root).await {
abort_download_best_effort(&ownership, &game_id).await;
if cancel_token.is_cancelled() {
eyre::bail!("download cancelled for game {game_id}");
@@ -90,10 +101,10 @@ pub(crate) async fn download_game_files(
return Err(error.into());
}
let progress_tracker = DownloadProgressTracker::new(total_download_bytes(&transfer_descs));
let progress_tracker = DownloadProgressTracker::new(total_download_bytes(manifest.entries()));
let transfer_ctx = TransferContext {
game_id: &game_id,
games_folder: &games_folder,
game_root: &confined_root,
peers: &peers,
file_peer_map: &file_peer_map,
tx_notify_ui: &tx_notify_ui,
@@ -105,7 +116,7 @@ pub(crate) async fn download_game_files(
&game_id,
progress_tracker,
tx_notify_ui.clone(),
download_transfer_chunks(&transfer_ctx, &transfer_descs),
download_transfer_chunks(&transfer_ctx, manifest.entries()),
)
.await;
@@ -119,7 +130,7 @@ pub(crate) async fn download_game_files(
eyre::bail!("download cancelled for game {game_id}");
}
if let Err(error) = sync_game_storage(&manifest).await {
if let Err(error) = sync_game_storage(&manifest, &confined_root).await {
abort_download_best_effort(&ownership, &game_id).await;
return Err(error.wrap_err("failed to make downloaded payload durable"));
}
@@ -137,7 +148,7 @@ pub(crate) async fn download_game_files(
eyre::bail!("download cancelled for game {game_id}");
}
match commit_version_ini_buffer(&game_root, &version_buffer).await {
match commit_version_ini_buffer(&confined_root, &version_buffer).await {
Ok(VersionIniCommit::Durable) => {}
Ok(VersionIniCommit::NeedsRecovery(error)) => {
// The visible sentinel makes rollback unsafe. Keep pending ownership
@@ -160,7 +171,7 @@ pub(crate) async fn download_game_files(
Ok(())
}
async fn restore_before_ownership_journal(game_root: &Path) -> eyre::Result<()> {
async fn restore_before_ownership_journal(game_root: &ConfinedGameRoot) -> eyre::Result<()> {
restore_unjournaled_version_ini_transaction(game_root).await
}
@@ -172,7 +183,7 @@ async fn abort_download_best_effort(ownership: &DownloadOwnershipTransaction, ga
struct TransferContext<'a> {
game_id: &'a str,
games_folder: &'a Path,
game_root: &'a ConfinedGameRoot,
peers: &'a [SocketAddr],
file_peer_map: &'a HashMap<String, Vec<SocketAddr>>,
tx_notify_ui: &'a UnboundedSender<PeerEvent>,
@@ -183,13 +194,13 @@ struct TransferContext<'a> {
async fn download_transfer_chunks(
ctx: &TransferContext<'_>,
transfer_descs: &[GameFileDescription],
transfer_descs: &[ValidatedDownloadEntry],
) -> eyre::Result<()> {
let plans = build_peer_plans(ctx.peers, transfer_descs, ctx.file_peer_map);
let mut tasks = Vec::new();
for (peer_addr, plan) in plans {
let base_dir = ctx.games_folder.to_path_buf();
let game_root = ctx.game_root.clone();
let game_id = ctx.game_id.to_string();
let cancel_token = ctx.cancel_token.clone();
let version_buffer = ctx.version_buffer.clone();
@@ -199,7 +210,7 @@ async fn download_transfer_chunks(
peer_addr,
&game_id,
plan,
base_dir,
game_root,
&cancel_token,
Some(version_buffer),
progress_tracker,
@@ -266,7 +277,7 @@ fn collect_chunk_results(
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished {
id: game_id.to_string(),
peer_addr: chunk_result.peer_addr,
relative_path: chunk_result.chunk.relative_path,
relative_path: chunk_result.chunk.request_path,
offset: chunk_result.chunk.offset,
length: chunk_result.chunk.length,
});
@@ -284,7 +295,7 @@ fn collect_chunk_results(
} else {
*last_err = Some(eyre::eyre!(
"Max retries exceeded for chunk: {}",
chunk_result.chunk.relative_path
chunk_result.chunk.request_path
));
}
}
@@ -305,7 +316,7 @@ async fn retry_chunks(
let retry_ctx = RetryContext {
peers: ctx.peers,
base_dir: ctx.games_folder,
game_root: ctx.game_root,
game_id: ctx.game_id,
file_peer_map: ctx.file_peer_map,
cancel_token: ctx.cancel_token,
@@ -335,7 +346,7 @@ async fn retry_chunks(
.send(PeerEvent::DownloadGameFileChunkFinished {
id: ctx.game_id.to_string(),
peer_addr: chunk_result.peer_addr,
relative_path: chunk_result.chunk.relative_path,
relative_path: chunk_result.chunk.request_path,
offset: chunk_result.chunk.offset,
length: chunk_result.chunk.length,
});
@@ -350,9 +361,9 @@ async fn retry_chunks(
Ok(())
}
fn total_download_bytes(file_descs: &[GameFileDescription]) -> u64 {
fn total_download_bytes(file_descs: &[ValidatedDownloadEntry]) -> u64 {
file_descs
.iter()
.filter(|desc| !desc.is_dir)
.fold(0u64, |total, desc| total.saturating_add(desc.file_size()))
.filter(|entry| !entry.is_dir())
.fold(0u64, |total, entry| total.saturating_add(entry.size()))
}
+133 -99
View File
@@ -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<String>,
current: BTreeSet<String>,
}
@@ -99,10 +109,17 @@ impl DownloadOwnershipTransaction {
pub(super) async fn prepare(
state_dir: &Path,
manifest: &ValidatedDownloadManifest,
game_root: &ConfinedGameRoot,
) -> eyre::Result<Self> {
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<OwnershipJournalPublication> {
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::<BTreeSet<_>>();
let pending = pending.into_iter().collect::<BTreeSet<_>>();
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::<BTreeSet<_>>();
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<OwnershipJournalPublication> {
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<OwnershipJournalPublication> {
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<String>) -> 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<bool> {
validate_owned_file_path(relative_path)?;
let mut destination = game_root.to_path_buf();
let components = relative_path.split('/').collect::<Vec<_>>();
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<String>,
) -> eyre::Result<()> {
let paths = paths
.iter()
.map(|path| ValidatedDownloadPath::from_ownership(path))
.collect::<eyre::Result<Vec<_>>>()?;
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<bool> {
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());
+24 -126
View File
@@ -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<GameFileDescription>,
) -> eyre::Result<(GameFileDescription, Vec<GameFileDescription>)> {
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<String, Vec<SocketAddr>>,
) -> HashMap<SocketAddr, PeerDownloadPlan> {
let mut plans: HashMap<SocketAddr, PeerDownloadPlan> = HashMap::new();
@@ -84,9 +57,9 @@ pub(super) fn build_peer_plans(
let mut planned_bytes: HashMap<SocketAddr, u64> = 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());
}
}
+9 -9
View File
@@ -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<String, Vec<SocketAddr>>,
pub(super) cancel_token: &'a CancellationToken,
@@ -72,14 +72,14 @@ fn plan_retry_batch(
let mut retry_plans: HashMap<SocketAddr, PeerDownloadPlan> = 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,
});
+19 -83
View File
@@ -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::<Vec<PathBuf>>();
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");
+21 -44
View File
@@ -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<Arc<VersionIniBuffer>>,
progress_tracker: Arc<DownloadProgressTracker>,
) -> 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<Arc<VersionIniBuffer>>,
@@ -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<Arc<VersionIniBuffer>>,
progress_tracker: Arc<DownloadProgressTracker>,
@@ -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<Arc<VersionIniBuffer>>,
progress_tracker: Arc<DownloadProgressTracker>,
@@ -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,
+126 -155
View File
@@ -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<Self> {
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<Self> {
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<VersionIniCommit> {
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<std::io::Error>,
) -> eyre::Result<VersionIniCommit> {
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<bool> {
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());
}
}
@@ -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 {
@@ -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.