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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ambient-authority"
version = "0.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b"
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.6" version = "0.1.6"
@@ -278,6 +284,35 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "cargo-platform" name = "cargo-platform"
version = "0.1.9" version = "0.1.9"
@@ -972,6 +1007,17 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "fs_extra" name = "fs_extra"
version = "1.3.0" version = "1.3.0"
@@ -1758,6 +1804,28 @@ version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4275b20e6057cd7733fd8df8a5a31701e4fe44497dad0f3fa0e1c4fb971506be" 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]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.1" version = "2.12.1"
@@ -1965,6 +2033,8 @@ name = "lanspread-peer"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bytes", "bytes",
"cap-fs-ext",
"cap-primitives",
"crc32fast", "crc32fast",
"eyre", "eyre",
"futures", "futures",
@@ -2170,6 +2240,12 @@ dependencies = [
"web_atoms", "web_atoms",
] ]
[[package]]
name = "maybe-owned"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4"
[[package]] [[package]]
name = "mdns-sd" name = "mdns-sd"
version = "0.20.3" version = "0.20.3"
@@ -3088,6 +3164,16 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.43" version = "0.23.43"
@@ -5634,6 +5720,16 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"
+2
View File
@@ -14,6 +14,8 @@ members = [
[workspace.dependencies] [workspace.dependencies]
base64 = "0.23" base64 = "0.23"
bytes = { version = "1", features = ["serde"] } bytes = { version = "1", features = ["serde"] }
cap-fs-ext = { version = "4", default-features = false }
cap-primitives = "4"
crc32fast = "1" crc32fast = "1"
eyre = "0.6" eyre = "0.6"
futures = "0.3" 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 - `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 file. The sentinel is written last through `.version.ini.tmp` and atomic
rename. An interrupted replacement leaves no restored old sentinel because rename. The old sentinel is parked before a pending ownership set is
archive bytes may already have changed. 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 - `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 `local/` are user-owned and are skipped by manifests, fingerprints, and file
serving. 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 - Keep `GetGame`/manifest requests, but keyed by `manifest_hash` so repeated
calls can be skipped when unchanged. 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. - 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 - 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. 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 # external
bytes = { workspace = true } bytes = { workspace = true }
cap-fs-ext = { workspace = true }
cap-primitives = { workspace = true }
crc32fast = { workspace = true } crc32fast = { workspace = true }
eyre = { workspace = true } eyre = { workspace = true }
futures = { 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. /// One entry whose path and shape were validated as part of a complete manifest.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct ValidatedDownloadEntry { pub(crate) struct ValidatedDownloadEntry {
canonical_path: String, destination: ValidatedDownloadPath,
protocol_path: String, protocol_path: String,
is_dir: bool, is_dir: bool,
size: u64, size: u64,
} }
impl ValidatedDownloadEntry { impl ValidatedDownloadEntry {
pub(crate) fn canonical_path(&self) -> &str { pub(super) const fn destination(&self) -> &ValidatedDownloadPath {
&self.canonical_path &self.destination
} }
#[cfg(test)] pub(super) fn protocol_path(&self) -> &str {
fn protocol_path(&self) -> &str {
&self.protocol_path &self.protocol_path
} }
@@ -51,7 +50,7 @@ impl ValidatedDownloadEntry {
} }
pub(crate) fn is_version_ini(&self) -> bool { 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 { pub(crate) fn protocol_description(&self, game_id: &str) -> GameFileDescription {
@@ -62,6 +61,42 @@ impl ValidatedDownloadEntry {
size: self.size, 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. /// A complete download description validated before any filesystem mutation.
@@ -69,7 +104,6 @@ impl ValidatedDownloadEntry {
pub(crate) struct ValidatedDownloadManifest { pub(crate) struct ValidatedDownloadManifest {
game_id: String, game_id: String,
games_folder: PathBuf, games_folder: PathBuf,
game_root: PathBuf,
entries: Vec<ValidatedDownloadEntry>, entries: Vec<ValidatedDownloadEntry>,
} }
@@ -105,7 +139,6 @@ impl ValidatedDownloadManifest {
Ok(Self { Ok(Self {
game_id: game_id.to_owned(), game_id: game_id.to_owned(),
games_folder, games_folder,
game_root,
entries, entries,
}) })
} }
@@ -118,12 +151,7 @@ impl ValidatedDownloadManifest {
&self.games_folder &self.games_folder
} }
pub(crate) fn game_root(&self) -> &Path { pub(super) fn entries(&self) -> &[ValidatedDownloadEntry] {
&self.game_root
}
#[cfg(test)]
fn entries(&self) -> &[ValidatedDownloadEntry] {
&self.entries &self.entries
} }
@@ -131,17 +159,17 @@ impl ValidatedDownloadManifest {
self.entries.iter().filter(|entry| !entry.is_version_ini()) 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 self.entries
.iter() .iter()
.map(|entry| entry.protocol_description(&self.game_id)) .find(|entry| entry.is_version_ini())
.collect() .expect("validated manifests contain one version.ini")
} }
pub(super) fn owned_file_paths(&self) -> Vec<String> { pub(super) fn owned_file_paths(&self) -> Vec<String> {
self.transfer_entries() self.transfer_entries()
.filter(|entry| !entry.is_dir()) .filter(|entry| !entry.is_dir())
.map(|entry| entry.canonical_path.clone()) .map(|entry| entry.destination.canonical.clone())
.collect() .collect()
} }
} }
@@ -249,7 +277,7 @@ impl<'a> ProtocolV7ManifestBuilder<'a> {
)?; )?;
} }
self.entries.push(ValidatedDownloadEntry { self.entries.push(ValidatedDownloadEntry {
canonical_path: canonical_path.to_owned(), destination: ValidatedDownloadPath::new(canonical_path.to_owned()),
protocol_path: description.relative_path, protocol_path: description.relative_path,
is_dir: description.is_dir, is_dir: description.is_dir,
size: description.size, size: description.size,
@@ -325,7 +353,7 @@ impl<'a> ProtocolV7ManifestBuilder<'a> {
fn finish(mut self) -> eyre::Result<Vec<ValidatedDownloadEntry>> { fn finish(mut self) -> eyre::Result<Vec<ValidatedDownloadEntry>> {
self.entries 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 let versions = self
.entries .entries
.iter() .iter()
@@ -720,7 +748,7 @@ mod tests {
let paths = manifest let paths = manifest
.entries() .entries()
.iter() .iter()
.map(ValidatedDownloadEntry::canonical_path) .map(|entry| entry.destination().canonical())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!(paths, ["archive.eti", "version.ini"]); assert_eq!(paths, ["archive.eti", "version.ini"]);
let version_ini = manifest let version_ini = manifest
@@ -1,5 +1,6 @@
//! Download pipeline for game files from peers. //! Download pipeline for game files from peers.
mod confined_fs;
mod manifest; mod manifest;
mod orchestrator; mod orchestrator;
mod ownership; mod ownership;
@@ -1,13 +1,13 @@
use std::{collections::HashMap, net::SocketAddr, path::Path, sync::Arc}; use std::{collections::HashMap, net::SocketAddr, path::Path, sync::Arc};
use lanspread_db::db::GameFileDescription;
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use super::{ use super::{
manifest::ValidatedDownloadManifest, confined_fs::ConfinedGameRoot,
ownership::DownloadOwnershipTransaction, manifest::{ValidatedDownloadEntry, ValidatedDownloadManifest},
planning::{ChunkDownloadResult, DownloadChunk, build_peer_plans, extract_version_descriptor}, ownership::{DownloadOwnershipTransaction, OwnershipJournalPublication},
planning::{ChunkDownloadResult, DownloadChunk, build_peer_plans},
progress::{DownloadProgressTracker, sample_download_progress}, progress::{DownloadProgressTracker, sample_download_progress},
retry::{RetryContext, retry_failed_chunks}, retry::{RetryContext, retry_failed_chunks},
storage::{prepare_game_storage, sync_game_storage}, storage::{prepare_game_storage, sync_game_storage},
@@ -33,7 +33,6 @@ pub(crate) async fn download_game_files(
cancel_token: CancellationToken, cancel_token: CancellationToken,
) -> eyre::Result<()> { ) -> eyre::Result<()> {
let game_id = manifest.game_id().to_owned(); let game_id = manifest.game_id().to_owned();
let games_folder = manifest.games_folder().to_path_buf();
if peers.is_empty() { if peers.is_empty() {
eyre::bail!("no peers available for game {game_id}"); 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}"); eyre::bail!("download cancelled for game {game_id}");
} }
let game_file_descs = manifest.protocol_descriptions(); let version_entry = manifest.version_entry();
let (version_desc, transfer_descs) = extract_version_descriptor(&game_id, game_file_descs)?; let version_buffer =
let version_buffer = match VersionIniBuffer::new(&version_desc) { match VersionIniBuffer::new(version_entry.protocol_path(), version_entry.size()) {
Ok(buffer) => Arc::new(buffer), Ok(buffer) => Arc::new(buffer),
Err(err) => return Err(err), Err(err) => return Err(err),
}; };
let game_root = manifest.game_root().to_path_buf(); let confined_root = ConfinedGameRoot::open_or_create(manifest.games_folder(), &game_id).await?;
let ownership = DownloadOwnershipTransaction::prepare(state_dir, &manifest).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(error) = begin_version_ini_transaction(&confined_root).await {
if let Err(restore_error) = restore_before_ownership_journal(&game_root).await { if let Err(restore_error) = restore_before_ownership_journal(&confined_root).await {
return Err(error.wrap_err(format!( return Err(error.wrap_err(format!(
"sentinel parking failed and rollback also failed: {restore_error}" "sentinel parking failed and rollback also failed: {restore_error}"
))); )));
@@ -60,18 +60,29 @@ pub(crate) async fn download_game_files(
return Err(error); return Err(error);
} }
if cancel_token.is_cancelled() { 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}"); eyre::bail!("download cancelled for game {game_id}");
} }
if let Err(error) = ownership.journal_pending().await { match ownership.journal_pending().await {
if let Err(restore_error) = restore_before_ownership_journal(&game_root).await { Ok(OwnershipJournalPublication::Durable) => {}
return Err(error.wrap_err(format!( Ok(OwnershipJournalPublication::NeedsRecovery(error)) => {
"ownership journal failed and sentinel restore also failed: {restore_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; abort_download_best_effort(&ownership, &game_id).await;
if cancel_token.is_cancelled() { if cancel_token.is_cancelled() {
eyre::bail!("download cancelled for game {game_id}"); eyre::bail!("download cancelled for game {game_id}");
@@ -90,10 +101,10 @@ pub(crate) async fn download_game_files(
return Err(error.into()); 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 { let transfer_ctx = TransferContext {
game_id: &game_id, game_id: &game_id,
games_folder: &games_folder, game_root: &confined_root,
peers: &peers, peers: &peers,
file_peer_map: &file_peer_map, file_peer_map: &file_peer_map,
tx_notify_ui: &tx_notify_ui, tx_notify_ui: &tx_notify_ui,
@@ -105,7 +116,7 @@ pub(crate) async fn download_game_files(
&game_id, &game_id,
progress_tracker, progress_tracker,
tx_notify_ui.clone(), tx_notify_ui.clone(),
download_transfer_chunks(&transfer_ctx, &transfer_descs), download_transfer_chunks(&transfer_ctx, manifest.entries()),
) )
.await; .await;
@@ -119,7 +130,7 @@ pub(crate) async fn download_game_files(
eyre::bail!("download cancelled for game {game_id}"); 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; abort_download_best_effort(&ownership, &game_id).await;
return Err(error.wrap_err("failed to make downloaded payload durable")); 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}"); 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::Durable) => {}
Ok(VersionIniCommit::NeedsRecovery(error)) => { Ok(VersionIniCommit::NeedsRecovery(error)) => {
// The visible sentinel makes rollback unsafe. Keep pending ownership // The visible sentinel makes rollback unsafe. Keep pending ownership
@@ -160,7 +171,7 @@ pub(crate) async fn download_game_files(
Ok(()) 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 restore_unjournaled_version_ini_transaction(game_root).await
} }
@@ -172,7 +183,7 @@ async fn abort_download_best_effort(ownership: &DownloadOwnershipTransaction, ga
struct TransferContext<'a> { struct TransferContext<'a> {
game_id: &'a str, game_id: &'a str,
games_folder: &'a Path, game_root: &'a ConfinedGameRoot,
peers: &'a [SocketAddr], peers: &'a [SocketAddr],
file_peer_map: &'a HashMap<String, Vec<SocketAddr>>, file_peer_map: &'a HashMap<String, Vec<SocketAddr>>,
tx_notify_ui: &'a UnboundedSender<PeerEvent>, tx_notify_ui: &'a UnboundedSender<PeerEvent>,
@@ -183,13 +194,13 @@ struct TransferContext<'a> {
async fn download_transfer_chunks( async fn download_transfer_chunks(
ctx: &TransferContext<'_>, ctx: &TransferContext<'_>,
transfer_descs: &[GameFileDescription], transfer_descs: &[ValidatedDownloadEntry],
) -> eyre::Result<()> { ) -> eyre::Result<()> {
let plans = build_peer_plans(ctx.peers, transfer_descs, ctx.file_peer_map); let plans = build_peer_plans(ctx.peers, transfer_descs, ctx.file_peer_map);
let mut tasks = Vec::new(); let mut tasks = Vec::new();
for (peer_addr, plan) in plans { 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 game_id = ctx.game_id.to_string();
let cancel_token = ctx.cancel_token.clone(); let cancel_token = ctx.cancel_token.clone();
let version_buffer = ctx.version_buffer.clone(); let version_buffer = ctx.version_buffer.clone();
@@ -199,7 +210,7 @@ async fn download_transfer_chunks(
peer_addr, peer_addr,
&game_id, &game_id,
plan, plan,
base_dir, game_root,
&cancel_token, &cancel_token,
Some(version_buffer), Some(version_buffer),
progress_tracker, progress_tracker,
@@ -266,7 +277,7 @@ fn collect_chunk_results(
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished { let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished {
id: game_id.to_string(), id: game_id.to_string(),
peer_addr: chunk_result.peer_addr, 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, offset: chunk_result.chunk.offset,
length: chunk_result.chunk.length, length: chunk_result.chunk.length,
}); });
@@ -284,7 +295,7 @@ fn collect_chunk_results(
} else { } else {
*last_err = Some(eyre::eyre!( *last_err = Some(eyre::eyre!(
"Max retries exceeded for chunk: {}", "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 { let retry_ctx = RetryContext {
peers: ctx.peers, peers: ctx.peers,
base_dir: ctx.games_folder, game_root: ctx.game_root,
game_id: ctx.game_id, game_id: ctx.game_id,
file_peer_map: ctx.file_peer_map, file_peer_map: ctx.file_peer_map,
cancel_token: ctx.cancel_token, cancel_token: ctx.cancel_token,
@@ -335,7 +346,7 @@ async fn retry_chunks(
.send(PeerEvent::DownloadGameFileChunkFinished { .send(PeerEvent::DownloadGameFileChunkFinished {
id: ctx.game_id.to_string(), id: ctx.game_id.to_string(),
peer_addr: chunk_result.peer_addr, 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, offset: chunk_result.chunk.offset,
length: chunk_result.chunk.length, length: chunk_result.chunk.length,
}); });
@@ -350,9 +361,9 @@ async fn retry_chunks(
Ok(()) Ok(())
} }
fn total_download_bytes(file_descs: &[GameFileDescription]) -> u64 { fn total_download_bytes(file_descs: &[ValidatedDownloadEntry]) -> u64 {
file_descs file_descs
.iter() .iter()
.filter(|desc| !desc.is_dir) .filter(|entry| !entry.is_dir())
.fold(0u64, |total, desc| total.saturating_add(desc.file_size())) .fold(0u64, |total, entry| total.saturating_add(entry.size()))
} }
+133 -99
View File
@@ -6,14 +6,17 @@ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
use eyre::WrapErr as _;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use super::{ use super::{
confined_fs::ConfinedGameRoot,
manifest::{ manifest::{
MAX_DOWNLOAD_MANIFEST_ENTRIES, MAX_DOWNLOAD_MANIFEST_ENTRIES,
MAX_DOWNLOAD_RELATIVE_PATH_BYTES, MAX_DOWNLOAD_RELATIVE_PATH_BYTES,
ValidatedDownloadManifest, ValidatedDownloadManifest,
ValidatedDownloadPath,
validate_owned_file_path, validate_owned_file_path,
}, },
version_ini::{ version_ini::{
@@ -82,6 +85,13 @@ enum LoadedOwnership {
Valid(DownloadOwnershipRecord), 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. /// One download attempt whose previous and proposed ownership sets are durable.
#[derive(Debug)] #[derive(Debug)]
pub(super) struct DownloadOwnershipTransaction { pub(super) struct DownloadOwnershipTransaction {
@@ -89,7 +99,7 @@ pub(super) struct DownloadOwnershipTransaction {
tmp_path: PathBuf, tmp_path: PathBuf,
game_id: String, game_id: String,
games_folder_key: String, games_folder_key: String,
game_root: PathBuf, game_root: ConfinedGameRoot,
previous: BTreeSet<String>, previous: BTreeSet<String>,
current: BTreeSet<String>, current: BTreeSet<String>,
} }
@@ -99,10 +109,17 @@ impl DownloadOwnershipTransaction {
pub(super) async fn prepare( pub(super) async fn prepare(
state_dir: &Path, state_dir: &Path,
manifest: &ValidatedDownloadManifest, manifest: &ValidatedDownloadManifest,
game_root: &ConfinedGameRoot,
) -> eyre::Result<Self> { ) -> 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()); 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 record_path = download_ownership_path(state_dir, manifest.game_id());
let tmp_path = download_ownership_tmp_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 let previous = match load_record(&record_path, manifest.game_id(), &games_folder_key).await
@@ -111,7 +128,10 @@ impl DownloadOwnershipTransaction {
LoadedOwnership::Missing | LoadedOwnership::Invalid => { LoadedOwnership::Missing | LoadedOwnership::Invalid => {
let baseline = let baseline =
DownloadOwnershipRecord::empty(manifest.game_id(), &games_folder_key); 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() BTreeSet::new()
} }
}; };
@@ -123,14 +143,14 @@ impl DownloadOwnershipTransaction {
tmp_path, tmp_path,
game_id: manifest.game_id().to_owned(), game_id: manifest.game_id().to_owned(),
games_folder_key, games_folder_key,
game_root: manifest.game_root().to_path_buf(), game_root: game_root.clone(),
previous, previous,
current, current,
}) })
} }
/// Publishes the proposed set after the old sentinel has been parked. /// 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 { let record = DownloadOwnershipRecord {
schema_version: OWNERSHIP_SCHEMA_VERSION, schema_version: OWNERSHIP_SCHEMA_VERSION,
game_id: self.game_id.clone(), game_id: self.game_id.clone(),
@@ -161,7 +181,10 @@ impl DownloadOwnershipTransaction {
remove_owned_files(&self.game_root, &removable).await?; remove_owned_files(&self.game_root, &removable).await?;
discard_version_ini_transaction(&self.game_root).await?; discard_version_ini_transaction(&self.game_root).await?;
let empty = DownloadOwnershipRecord::empty(&self.game_id, &self.games_folder_key); 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. /// Finalizes ownership after the new `version.ini` commit point has landed.
@@ -173,7 +196,10 @@ impl DownloadOwnershipTransaction {
committed_files: self.current.iter().cloned().collect(), committed_files: self.current.iter().cloned().collect(),
pending_files: None, 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) if error.kind() == ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()), 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); 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 path = download_ownership_path(state_dir, game_id);
let tmp_path = download_ownership_tmp_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 => { LoadedOwnership::Missing => {
// Pre-journal versions used the same scratch name after payload // Pre-journal versions used the same scratch name after payload
// mutation. Without a new-format baseline, restoring it could // mutation. Without a new-format baseline, restoring it could
@@ -222,7 +266,10 @@ pub(crate) async fn recover_incomplete_download(
.cloned() .cloned()
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
let pending = pending.into_iter().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 let stale = committed
.difference(&pending) .difference(&pending)
.cloned() .cloned()
@@ -231,13 +278,19 @@ pub(crate) async fn recover_incomplete_download(
finish_recovered_version_ini_transaction(game_root).await?; finish_recovered_version_ini_transaction(game_root).await?;
record.committed_files = pending.into_iter().collect(); record.committed_files = pending.into_iter().collect();
record.pending_files = None; 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 { } else {
let removable = committed.union(&pending).cloned().collect::<BTreeSet<_>>(); let removable = committed.union(&pending).cloned().collect::<BTreeSet<_>>();
remove_owned_files(game_root, &removable).await?; remove_owned_files(game_root, &removable).await?;
discard_version_ini_transaction(game_root).await?; discard_version_ini_transaction(game_root).await?;
let empty = DownloadOwnershipRecord::empty(game_id, &key); let empty = DownloadOwnershipRecord::empty(game_id, games_folder_key);
write_record(&path, &tmp_path, &empty).await?; require_durable_record(
write_record(&path, &tmp_path, &empty).await?,
"recovered aborted download ownership",
)?;
} }
} }
} }
@@ -355,7 +408,7 @@ async fn write_record(
path: &Path, path: &Path,
tmp_path: &Path, tmp_path: &Path,
record: &DownloadOwnershipRecord, record: &DownloadOwnershipRecord,
) -> eyre::Result<()> { ) -> eyre::Result<OwnershipJournalPublication> {
write_record_with_parent_sync(path, tmp_path, record, sync_parent_dir).await 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, tmp_path: &Path,
record: &DownloadOwnershipRecord, record: &DownloadOwnershipRecord,
sync_parent: impl FnOnce(&Path) -> std::io::Result<()>, sync_parent: impl FnOnce(&Path) -> std::io::Result<()>,
) -> eyre::Result<()> { ) -> eyre::Result<OwnershipJournalPublication> {
let parent = path let parent = path
.parent() .parent()
.ok_or_else(|| eyre::eyre!("download ownership path has no 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); drop(file);
tokio::fs::rename(tmp_path, path).await?; tokio::fs::rename(tmp_path, path).await?;
if let Err(error) = sync_parent(path) { if let Err(error) = sync_parent(path) {
// The rename is the publication point. Reporting an error from here return Ok(OwnershipJournalPublication::NeedsRecovery(error));
// 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()
);
} }
Ok(()) Ok(OwnershipJournalPublication::Durable)
} }
async fn remove_owned_files(game_root: &Path, paths: &BTreeSet<String>) -> eyre::Result<()> { fn require_durable_record(
for relative_path in paths { publication: OwnershipJournalPublication,
remove_owned_regular_file(game_root, relative_path).await?; 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> { async fn remove_owned_files(
validate_owned_file_path(relative_path)?; game_root: &ConfinedGameRoot,
let mut destination = game_root.to_path_buf(); paths: &BTreeSet<String>,
let components = relative_path.split('/').collect::<Vec<_>>(); ) -> eyre::Result<()> {
for (index, component) in components.iter().enumerate() { let paths = paths
destination.push(component); .iter()
let metadata = match tokio::fs::symlink_metadata(&destination).await { .map(|path| ValidatedDownloadPath::from_ownership(path))
Ok(metadata) => metadata, .collect::<eyre::Result<Vec<_>>>()?;
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false), game_root.remove_owned_regular_files(paths).await
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 create_state_parent_durably(path: &Path) -> eyre::Result<()> { 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(()) 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<()> { async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> {
match tokio::fs::remove_file(path).await { match tokio::fs::remove_file(path).await {
Ok(()) => Ok(()), Ok(()) => Ok(()),
@@ -590,13 +605,17 @@ mod tests {
committed_files: committed.iter().map(ToString::to_string).collect(), committed_files: committed.iter().map(ToString::to_string).collect(),
pending_files: pending.map(|paths| paths.iter().map(ToString::to_string).collect()), pending_files: pending.map(|paths| paths.iter().map(ToString::to_string).collect()),
}; };
write_record( require_durable_record(
&download_ownership_path(state_dir, "game"), write_record(
&download_ownership_tmp_path(state_dir, "game"), &download_ownership_path(state_dir, "game"),
&record, &download_ownership_tmp_path(state_dir, "game"),
&record,
)
.await
.expect("record should be published"),
"test ownership",
) )
.await .expect("record should be durable");
.expect("record should be written");
} }
async fn read_valid_record(state_dir: &Path, games_folder: &Path) -> DownloadOwnershipRecord { 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] #[tokio::test]
async fn journal_is_sorted_bound_and_ignores_stray_tmp() { async fn journal_is_sorted_bound_and_ignores_stray_tmp() {
let games = TempDir::new("lanspread-ownership-games"); let games = TempDir::new("lanspread-ownership-games");
let state = TempDir::new("lanspread-ownership-state"); let state = TempDir::new("lanspread-ownership-state");
let manifest = manifest(games.path(), &["z.eti", "nested/a.bin"]); let manifest = manifest(games.path(), &["z.eti", "nested/a.bin"]);
let transaction = DownloadOwnershipTransaction::prepare(state.path(), &manifest) let game_root = confined_root(&manifest).await;
.await let transaction =
.expect("transaction should prepare"); DownloadOwnershipTransaction::prepare(state.path(), &manifest, &game_root)
.await
.expect("transaction should prepare");
transaction transaction
.journal_pending() .journal_pending()
.await .await
@@ -705,10 +732,12 @@ mod tests {
.await; .await;
let manifest = manifest(games.path(), &["keep.eti", "new.eti"]); let manifest = manifest(games.path(), &["keep.eti", "new.eti"]);
let transaction = DownloadOwnershipTransaction::prepare(state.path(), &manifest) let confined_root = confined_root(&manifest).await;
.await let transaction =
.expect("transaction should prepare"); DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root)
super::super::version_ini::begin_version_ini_transaction(&root) .await
.expect("transaction should prepare");
super::super::version_ini::begin_version_ini_transaction(&confined_root)
.await .await
.expect("sentinel should park"); .expect("sentinel should park");
transaction transaction
@@ -760,10 +789,12 @@ mod tests {
let root = games.game_root(); let root = games.game_root();
write_file(&root.join(VERSION_INI), b"20240101"); write_file(&root.join(VERSION_INI), b"20240101");
let manifest = manifest(games.path(), &["archive.eti"]); let manifest = manifest(games.path(), &["archive.eti"]);
let _transaction = DownloadOwnershipTransaction::prepare(state.path(), &manifest) let confined_root = confined_root(&manifest).await;
.await let _transaction =
.expect("baseline ownership should be durable"); DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root)
super::super::version_ini::begin_version_ini_transaction(&root) .await
.expect("baseline ownership should be durable");
super::super::version_ini::begin_version_ini_transaction(&confined_root)
.await .await
.expect("sentinel should park"); .expect("sentinel should park");
recover_incomplete_download(&root, state.path(), "game") recover_incomplete_download(&root, state.path(), "game")
@@ -864,7 +895,7 @@ mod tests {
} }
#[tokio::test] #[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 games = TempDir::new("lanspread-ownership-publish-games");
let state = TempDir::new("lanspread-ownership-publish-state"); let state = TempDir::new("lanspread-ownership-publish-state");
let record = DownloadOwnershipRecord { let record = DownloadOwnershipRecord {
@@ -877,12 +908,16 @@ mod tests {
let record_path = download_ownership_path(state.path(), "game"); let record_path = download_ownership_path(state.path(), "game");
let tmp_path = download_ownership_tmp_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")) Err(std::io::Error::other("injected parent sync failure"))
}) })
.await .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); 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"); write_file(&root.join("Archive.eti"), b"old");
seed_record(state.path(), games.path(), &["Archive.eti"], None).await; seed_record(state.path(), games.path(), &["Archive.eti"], None).await;
let error = DownloadOwnershipTransaction::prepare( let manifest = manifest(games.path(), &["archive.eti"]);
state.path(), let confined_root = confined_root(&manifest).await;
&manifest(games.path(), &["archive.eti"]), let error = DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root)
) .await
.await .expect_err("case-only ownership changes must fail closed");
.expect_err("case-only ownership changes must fail closed");
assert!(error.to_string().contains("portable filesystem alias")); assert!(error.to_string().contains("portable filesystem alias"));
assert!(root.join(VERSION_INI).is_file()); assert!(root.join(VERSION_INI).is_file());
+24 -126
View File
@@ -1,13 +1,13 @@
use std::{collections::HashMap, net::SocketAddr}; use std::{collections::HashMap, net::SocketAddr};
use lanspread_db::db::GameFileDescription; use super::manifest::{ValidatedDownloadEntry, ValidatedDownloadPath};
use crate::config::CHUNK_SIZE; use crate::config::CHUNK_SIZE;
/// Represents a chunk of a file to be downloaded. /// Represents a chunk of a file to be downloaded.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(super) struct DownloadChunk { pub(super) struct DownloadChunk {
pub(super) relative_path: String, pub(super) request_path: String,
pub(super) destination: ValidatedDownloadPath,
pub(super) offset: u64, pub(super) offset: u64,
pub(super) length: u64, pub(super) length: u64,
pub(super) retry_count: usize, pub(super) retry_count: usize,
@@ -28,33 +28,6 @@ pub(super) struct ChunkDownloadResult {
pub(super) peer_addr: SocketAddr, 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. /// Resolves which peers have a specific file.
pub(super) fn resolve_file_peers<'a>( pub(super) fn resolve_file_peers<'a>(
relative_path: &str, relative_path: &str,
@@ -73,7 +46,7 @@ pub(super) fn resolve_file_peers<'a>(
/// Builds download plans distributing files across peers. /// Builds download plans distributing files across peers.
pub(super) fn build_peer_plans( pub(super) fn build_peer_plans(
peers: &[SocketAddr], peers: &[SocketAddr],
file_descs: &[GameFileDescription], file_descs: &[ValidatedDownloadEntry],
file_peer_map: &HashMap<String, Vec<SocketAddr>>, file_peer_map: &HashMap<String, Vec<SocketAddr>>,
) -> HashMap<SocketAddr, PeerDownloadPlan> { ) -> HashMap<SocketAddr, PeerDownloadPlan> {
let mut plans: HashMap<SocketAddr, PeerDownloadPlan> = HashMap::new(); 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 planned_bytes: HashMap<SocketAddr, u64> = HashMap::new();
let mut tie_breaker = 0usize; let mut tie_breaker = 0usize;
for desc in file_descs.iter().filter(|d| !d.is_dir) { for desc in file_descs.iter().filter(|entry| !entry.is_dir()) {
let size = desc.file_size(); let size = desc.size();
let eligible_peers = resolve_file_peers(&desc.relative_path, file_peer_map, peers); let eligible_peers = resolve_file_peers(desc.protocol_path(), file_peer_map, peers);
if eligible_peers.is_empty() { if eligible_peers.is_empty() {
continue; continue;
} }
@@ -95,7 +68,8 @@ pub(super) fn build_peer_plans(
let peer = select_least_loaded_peer(eligible_peers, &planned_bytes, &mut tie_breaker); let peer = select_least_loaded_peer(eligible_peers, &planned_bytes, &mut tie_breaker);
*planned_bytes.entry(peer).or_default() += 1; *planned_bytes.entry(peer).or_default() += 1;
plans.entry(peer).or_default().chunks.push(DownloadChunk { 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, offset: 0,
length: 0, length: 0,
retry_count: 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); let peer = select_least_loaded_peer(eligible_peers, &planned_bytes, &mut tie_breaker);
*planned_bytes.entry(peer).or_default() += length; *planned_bytes.entry(peer).or_default() += length;
plans.entry(peer).or_default().chunks.push(DownloadChunk { 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, offset,
length, length,
retry_count: 0, retry_count: 0,
@@ -148,9 +123,13 @@ fn select_least_loaded_peer(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use lanspread_db::db::GameFileDescription;
use super::*; 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 { fn loopback_addr(port: u16) -> SocketAddr {
SocketAddr::from(([127, 0, 0, 1], port)) SocketAddr::from(([127, 0, 0, 1], port))
} }
@@ -161,12 +140,7 @@ mod tests {
let file_size = CHUNK_SIZE * 2 + CHUNK_SIZE / 4; let file_size = CHUNK_SIZE * 2 + CHUNK_SIZE / 4;
let mut file_peer_map = HashMap::new(); let mut file_peer_map = HashMap::new();
file_peer_map.insert("game/file.dat".to_string(), peers.clone()); file_peer_map.insert("game/file.dat".to_string(), peers.clone());
let file_descs = vec![GameFileDescription { let file_descs = vec![file("game/file.dat", file_size)];
game_id: "test".to_string(),
relative_path: "game/file.dat".to_string(),
is_dir: false,
size: file_size,
}];
let plans = build_peer_plans(&peers, &file_descs, &file_peer_map); let plans = build_peer_plans(&peers, &file_descs, &file_peer_map);
let mut chunks: Vec<_> = plans.values().flat_map(|plan| plan.chunks.iter()).collect(); 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(); let mut file_peer_map = HashMap::new();
file_peer_map.insert("game/version.ini".to_string(), peers.clone()); file_peer_map.insert("game/version.ini".to_string(), peers.clone());
file_peer_map.insert(large_file.to_string(), peers.clone()); file_peer_map.insert(large_file.to_string(), peers.clone());
let file_descs = vec![ let file_descs = vec![file("game/version.ini", 9), file(large_file, file_size)];
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 plans = build_peer_plans(&peers, &file_descs, &file_peer_map); let plans = build_peer_plans(&peers, &file_descs, &file_peer_map);
let mut chunk_counts = HashMap::new(); let mut chunk_counts = HashMap::new();
@@ -215,7 +176,7 @@ mod tests {
for (peer, plan) in plans { for (peer, plan) in plans {
for chunk in plan.chunks { 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; *chunk_counts.entry(peer).or_insert(0usize) += 1;
*byte_counts.entry(peer).or_insert(0u64) += chunk.length; *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]); file_peer_map.insert("exclusive.bin".to_string(), vec![exclusive]);
let file_descs = vec![ let file_descs = vec![
GameFileDescription { file("shared.bin", CHUNK_SIZE * 2),
game_id: "test".to_string(), file("exclusive.bin", CHUNK_SIZE),
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,
},
]; ];
let plans = build_peer_plans(&peers, &file_descs, &file_peer_map); let plans = build_peer_plans(&peers, &file_descs, &file_peer_map);
@@ -272,13 +223,13 @@ mod tests {
exclusive_plan exclusive_plan
.chunks .chunks
.iter() .iter()
.all(|chunk| chunk.relative_path == "exclusive.bin"), .all(|chunk| chunk.request_path == "exclusive.bin"),
"exclusive peer should only receive exclusive.bin chunks" "exclusive peer should only receive exclusive.bin chunks"
); );
for (peer, plan) in plans { for (peer, plan) in plans {
for chunk in plan.chunks { for chunk in plan.chunks {
match chunk.relative_path.as_str() { match chunk.request_path.as_str() {
"exclusive.bin" => assert_eq!( "exclusive.bin" => assert_eq!(
peer, exclusive, peer, exclusive,
"exclusive.bin chunks should only be assigned to the exclusive peer" "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::{ use std::{
collections::{HashMap, VecDeque}, collections::{HashMap, VecDeque},
net::SocketAddr, net::SocketAddr,
path::Path,
sync::Arc, sync::Arc,
}; };
@@ -9,6 +8,7 @@ use futures::{StreamExt, stream::FuturesUnordered};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use super::{ use super::{
confined_fs::ConfinedGameRoot,
planning::{ChunkDownloadResult, DownloadChunk, PeerDownloadPlan, resolve_file_peers}, planning::{ChunkDownloadResult, DownloadChunk, PeerDownloadPlan, resolve_file_peers},
progress::DownloadProgressTracker, progress::DownloadProgressTracker,
transport::download_from_peer, transport::download_from_peer,
@@ -55,7 +55,7 @@ struct RetryAttempt {
pub(super) struct RetryContext<'a> { pub(super) struct RetryContext<'a> {
pub(super) peers: &'a [SocketAddr], 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) game_id: &'a str,
pub(super) file_peer_map: &'a HashMap<String, Vec<SocketAddr>>, pub(super) file_peer_map: &'a HashMap<String, Vec<SocketAddr>>,
pub(super) cancel_token: &'a CancellationToken, pub(super) cancel_token: &'a CancellationToken,
@@ -72,14 +72,14 @@ fn plan_retry_batch(
let mut retry_plans: HashMap<SocketAddr, PeerDownloadPlan> = HashMap::new(); let mut retry_plans: HashMap<SocketAddr, PeerDownloadPlan> = HashMap::new();
while let Some(mut chunk) = queue.pop_front() { 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 { if chunk.retry_count >= MAX_RETRY_COUNT {
final_results.push(ChunkDownloadResult { final_results.push(ChunkDownloadResult {
chunk: chunk.clone(), chunk: chunk.clone(),
result: Err(eyre::eyre!( result: Err(eyre::eyre!(
"Retry budget exhausted for chunk: {}", "Retry budget exhausted for chunk: {}",
chunk.relative_path chunk.request_path
)), )),
peer_addr: fallback_peer_addr(eligible_peers, chunk.last_peer), peer_addr: fallback_peer_addr(eligible_peers, chunk.last_peer),
}); });
@@ -91,7 +91,7 @@ fn plan_retry_batch(
chunk: chunk.clone(), chunk: chunk.clone(),
result: Err(eyre::eyre!( result: Err(eyre::eyre!(
"No peers available to retry chunk: {}", "No peers available to retry chunk: {}",
chunk.relative_path chunk.request_path
)), )),
peer_addr: fallback_peer_addr(eligible_peers, chunk.last_peer), 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 { for (peer_addr, plan) in retry_plans {
let retry_chunks = plan.chunks.clone(); 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 game_id = ctx.game_id.to_string();
let cancel_token = ctx.cancel_token.clone(); let cancel_token = ctx.cancel_token.clone();
let version_buffer = ctx.version_buffer.clone(); let version_buffer = ctx.version_buffer.clone();
@@ -124,7 +124,7 @@ async fn run_retry_batch(
peer_addr, peer_addr,
&game_id, &game_id,
plan, plan,
base_dir, game_root,
&cancel_token, &cancel_token,
version_buffer, version_buffer,
progress_tracker, progress_tracker,
@@ -174,7 +174,7 @@ fn handle_retry_chunk_result(
chunk.last_peer = Some(peer_addr); chunk.last_peer = Some(peer_addr);
if chunk.retry_count >= MAX_RETRY_COUNT { 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 { final_results.push(ChunkDownloadResult {
chunk, chunk,
result: Err(err.wrap_err(context)), result: Err(err.wrap_err(context)),
@@ -205,7 +205,7 @@ fn handle_retry_attempt_error(
chunk: chunk.clone(), chunk: chunk.clone(),
result: Err(eyre::eyre!( result: Err(eyre::eyre!(
"Retry budget exhausted for chunk after connection failure: {}: {error}", "Retry budget exhausted for chunk after connection failure: {}: {error}",
chunk.relative_path chunk.request_path
)), )),
peer_addr, peer_addr,
}); });
+19 -83
View File
@@ -1,90 +1,23 @@
use std::{cmp::Reverse, collections::BTreeSet, path::PathBuf}; use super::{confined_fs::ConfinedGameRoot, manifest::ValidatedDownloadManifest};
use tokio::fs::OpenOptions;
use super::manifest::ValidatedDownloadManifest;
/// Prepares storage for game files by creating directories and pre-allocating files. /// Prepares storage for game files by creating directories and pre-allocating files.
pub(super) async fn prepare_game_storage(manifest: &ValidatedDownloadManifest) -> eyre::Result<()> { pub(super) async fn prepare_game_storage(
for entry in manifest.transfer_entries() { manifest: &ValidatedDownloadManifest,
let validated_path = manifest.game_root().join(entry.canonical_path()); game_root: &ConfinedGameRoot,
) -> eyre::Result<()> {
if entry.is_dir() { game_root
tokio::fs::create_dir_all(&validated_path).await?; .prepare_entries(manifest.transfer_entries().cloned().collect())
} else { .await
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(())
} }
/// Makes payload bytes and directory entries durable before the sentinel commit. /// Makes payload bytes and directory entries durable before the sentinel commit.
pub(super) async fn sync_game_storage(manifest: &ValidatedDownloadManifest) -> eyre::Result<()> { pub(super) async fn sync_game_storage(
let mut directories = BTreeSet::new(); manifest: &ValidatedDownloadManifest,
directories.insert(manifest.game_root().to_path_buf()); game_root: &ConfinedGameRoot,
) -> eyre::Result<()> {
for entry in manifest.transfer_entries() { game_root
let path = manifest.game_root().join(entry.canonical_path()); .sync_entries(manifest.transfer_entries().cloned().collect())
if entry.is_dir() { .await
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(())
} }
#[cfg(test)] #[cfg(test)]
@@ -111,7 +44,10 @@ mod tests {
) )
.expect("manifest should validate"); .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 .await
.expect("storage preparation should succeed"); .expect("storage preparation should succeed");
+21 -44
View File
@@ -1,31 +1,20 @@
use std::{ use std::{collections::VecDeque, net::SocketAddr, sync::Arc};
collections::VecDeque,
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
};
use futures::{SinkExt, StreamExt, stream::FuturesUnordered}; use futures::{SinkExt, StreamExt, stream::FuturesUnordered};
use s2n_quic::{Connection, stream::ReceiveStream}; use s2n_quic::{Connection, stream::ReceiveStream};
use tokio::{ use tokio::io::{AsyncSeekExt, AsyncWriteExt};
fs::OpenOptions,
io::{AsyncSeekExt, AsyncWriteExt},
};
use tokio_util::{ use tokio_util::{
codec::{FramedWrite, LengthDelimitedCodec}, codec::{FramedWrite, LengthDelimitedCodec},
sync::CancellationToken, sync::CancellationToken,
}; };
use super::{ use super::{
confined_fs::ConfinedGameRoot,
planning::{ChunkDownloadResult, DownloadChunk, PeerDownloadPlan}, planning::{ChunkDownloadResult, DownloadChunk, PeerDownloadPlan},
progress::DownloadProgressTracker, progress::DownloadProgressTracker,
version_ini::VersionIniBuffer, version_ini::VersionIniBuffer,
}; };
use crate::{ use crate::{config::PEER_DOWNLOAD_STREAM_WINDOW, network::connect_to_peer};
config::PEER_DOWNLOAD_STREAM_WINDOW,
network::connect_to_peer,
path_validation::validate_game_file_path,
};
fn ensure_download_not_cancelled( fn ensure_download_not_cancelled(
cancel_token: &CancellationToken, cancel_token: &CancellationToken,
@@ -92,7 +81,7 @@ async fn open_chunk_stream(
let request = Request::GetGameFileChunk { let request = Request::GetGameFileChunk {
game_id: game_id.to_string(), game_id: game_id.to_string(),
relative_path: chunk.relative_path.clone(), relative_path: chunk.request_path.clone(),
offset: chunk.offset, offset: chunk.offset,
length: chunk.length, length: chunk.length,
}; };
@@ -106,34 +95,23 @@ async fn open_chunk_stream(
async fn receive_chunk( async fn receive_chunk(
peer_addr: SocketAddr, peer_addr: SocketAddr,
mut rx: ReceiveStream, mut rx: ReceiveStream,
base_dir: &Path, game_root: &ConfinedGameRoot,
chunk: &DownloadChunk, chunk: &DownloadChunk,
version_buffer: Option<Arc<VersionIniBuffer>>, version_buffer: Option<Arc<VersionIniBuffer>>,
progress_tracker: Arc<DownloadProgressTracker>, progress_tracker: Arc<DownloadProgressTracker>,
) -> eyre::Result<()> { ) -> eyre::Result<()> {
if let Some(buffer) = version_buffer 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; return download_version_ini_chunk(peer_addr, rx, chunk, &buffer, progress_tracker).await;
} }
// Validate the path to prevent directory traversal let mut file = tokio::fs::File::from_std(game_root.open_chunk_file(&chunk.destination).await?);
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?;
}
file.seek(std::io::SeekFrom::Start(chunk.offset)).await?; file.seek(std::io::SeekFrom::Start(chunk.offset)).await?;
let mut receive_budget = ReceiveBudget::new(chunk.length); let mut receive_budget = ReceiveBudget::new(chunk.length);
let mut progress = 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? { while let Some(bytes) = rx.receive().await? {
receive_budget.accept(bytes.len())?; receive_budget.accept(bytes.len())?;
@@ -145,14 +123,14 @@ async fn receive_chunk(
file.flush().await?; file.flush().await?;
// Verify file integrity by checking the file size // 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(()) Ok(())
} }
async fn receive_chunk_result( async fn receive_chunk_result(
peer_addr: SocketAddr, peer_addr: SocketAddr,
base_dir: PathBuf, game_root: ConfinedGameRoot,
chunk: DownloadChunk, chunk: DownloadChunk,
rx: ReceiveStream, rx: ReceiveStream,
version_buffer: Option<Arc<VersionIniBuffer>>, version_buffer: Option<Arc<VersionIniBuffer>>,
@@ -161,7 +139,7 @@ async fn receive_chunk_result(
let result = receive_chunk( let result = receive_chunk(
peer_addr, peer_addr,
rx, rx,
&base_dir, &game_root,
&chunk, &chunk,
version_buffer, version_buffer,
progress_tracker, 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 received = Vec::with_capacity(usize::try_from(chunk.length)?);
let mut receive_budget = ReceiveBudget::new(chunk.length); let mut receive_budget = ReceiveBudget::new(chunk.length);
let mut progress = 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? { while let Some(bytes) = rx.receive().await? {
receive_budget.accept(bytes.len())?; receive_budget.accept(bytes.len())?;
progress.record_bytes(bytes.len()); progress.record_bytes(bytes.len());
@@ -197,7 +175,7 @@ async fn download_version_ini_chunk(
/// Verifies that a chunk was written correctly. /// Verifies that a chunk was written correctly.
async fn verify_chunk_integrity( async fn verify_chunk_integrity(
file_path: &Path, file: &tokio::fs::File,
offset: u64, offset: u64,
expected_length: u64, expected_length: u64,
) -> eyre::Result<()> { ) -> eyre::Result<()> {
@@ -205,7 +183,7 @@ async fn verify_chunk_integrity(
return Ok(()); // Skip verification for whole files or zero-length chunks 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(); let file_size = metadata.len();
if file_size < offset + expected_length { if file_size < offset + expected_length {
@@ -247,7 +225,7 @@ fn failed_plan_results(
struct ChunkPlanContext<'a> { struct ChunkPlanContext<'a> {
peer_addr: SocketAddr, peer_addr: SocketAddr,
game_id: &'a str, game_id: &'a str,
base_dir: &'a Path, game_root: &'a ConfinedGameRoot,
cancel_token: &'a CancellationToken, cancel_token: &'a CancellationToken,
version_buffer: Option<Arc<VersionIniBuffer>>, version_buffer: Option<Arc<VersionIniBuffer>>,
progress_tracker: Arc<DownloadProgressTracker>, progress_tracker: Arc<DownloadProgressTracker>,
@@ -262,7 +240,7 @@ async fn download_chunk_plan(
let mut in_flight = FuturesUnordered::new(); let mut in_flight = FuturesUnordered::new();
let mut results = Vec::new(); let mut results = Vec::new();
let window = PEER_DOWNLOAD_STREAM_WINDOW.max(1); 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 !pending.is_empty() || !in_flight.is_empty() {
while in_flight.len() < window { while in_flight.len() < window {
@@ -273,7 +251,7 @@ async fn download_chunk_plan(
log::info!( log::info!(
"Downloading chunk {} (offset {}, length {}) from {}", "Downloading chunk {} (offset {}, length {}) from {}",
chunk.relative_path, chunk.request_path,
chunk.offset, chunk.offset,
chunk.length, chunk.length,
ctx.peer_addr ctx.peer_addr
@@ -283,7 +261,7 @@ async fn download_chunk_plan(
Ok(rx) => { Ok(rx) => {
in_flight.push(receive_chunk_result( in_flight.push(receive_chunk_result(
ctx.peer_addr, ctx.peer_addr,
base_dir.clone(), game_root.clone(),
chunk, chunk,
rx, rx,
ctx.version_buffer.clone(), ctx.version_buffer.clone(),
@@ -326,7 +304,7 @@ pub(super) async fn download_from_peer(
peer_addr: SocketAddr, peer_addr: SocketAddr,
game_id: &str, game_id: &str,
plan: PeerDownloadPlan, plan: PeerDownloadPlan,
games_folder: PathBuf, game_root: ConfinedGameRoot,
cancel_token: &CancellationToken, cancel_token: &CancellationToken,
version_buffer: Option<Arc<VersionIniBuffer>>, version_buffer: Option<Arc<VersionIniBuffer>>,
progress_tracker: Arc<DownloadProgressTracker>, progress_tracker: Arc<DownloadProgressTracker>,
@@ -351,11 +329,10 @@ pub(super) async fn download_from_peer(
return Ok(failed_plan_results(plan, peer_addr, err)); return Ok(failed_plan_results(plan, peer_addr, err));
} }
let base_dir = games_folder;
let chunk_ctx = ChunkPlanContext { let chunk_ctx = ChunkPlanContext {
peer_addr, peer_addr,
game_id, game_id,
base_dir: &base_dir, game_root: &game_root,
cancel_token, cancel_token,
version_buffer, version_buffer,
progress_tracker, 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 tokio::{io::AsyncWriteExt, sync::Mutex};
use super::confined_fs::ConfinedGameRoot;
use crate::game_paths::{VERSION_DISCARDED_FILE, VERSION_INI, VERSION_TMP_FILE}; use crate::game_paths::{VERSION_DISCARDED_FILE, VERSION_INI, VERSION_TMP_FILE};
pub(super) enum VersionIniCommit { pub(super) enum VersionIniCommit {
@@ -19,13 +17,10 @@ pub(super) struct VersionIniBuffer {
} }
impl VersionIniBuffer { impl VersionIniBuffer {
pub(super) fn new(desc: &GameFileDescription) -> eyre::Result<Self> { pub(super) fn new(relative_path: &str, size: u64) -> eyre::Result<Self> {
if desc.is_dir { let size = usize::try_from(size)?;
eyre::bail!("version.ini sentinel cannot be a directory");
}
let size = usize::try_from(desc.size)?;
Ok(Self { Ok(Self {
relative_path: desc.relative_path.clone(), relative_path: relative_path.to_owned(),
bytes: Mutex::new(vec![0; size]), 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<()> { pub(super) async fn begin_version_ini_transaction(
tokio::fs::create_dir_all(game_root).await?; game_root: &ConfinedGameRoot,
sync_parent_dir(game_root)?; ) -> eyre::Result<()> {
remove_file_if_exists(&game_root.join(VERSION_TMP_FILE)).await?; game_root
remove_file_if_exists(&game_root.join(VERSION_DISCARDED_FILE)).await?; .remove_root_file_if_exists(VERSION_TMP_FILE)
sync_game_root(game_root)?; .await?;
game_root
.remove_root_file_if_exists(VERSION_DISCARDED_FILE)
.await?;
let version_path = game_root.join(VERSION_INI); if game_root.root_regular_file_exists(VERSION_INI).await? {
if tokio::fs::metadata(&version_path) game_root
.await .rename_root_file(VERSION_INI, VERSION_DISCARDED_FILE)
.is_ok_and(|metadata| metadata.is_file()) .await?;
{ game_root.sync_root().await?;
tokio::fs::rename(version_path, game_root.join(VERSION_DISCARDED_FILE)).await?;
sync_parent_dir(&game_root.join(VERSION_DISCARDED_FILE))?;
} }
Ok(()) Ok(())
} }
#[cfg(test)] #[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 { if let Err(err) = discard_version_ini_transaction(game_root).await {
log::warn!( log::warn!(
"Failed to discard version.ini transaction in {}: {err}", "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. /// Restores the old sentinel after a crash or failure before ownership journaling.
pub(super) async fn restore_unjournaled_version_ini_transaction( pub(super) async fn restore_unjournaled_version_ini_transaction(
game_root: &Path, game_root: &ConfinedGameRoot,
) -> eyre::Result<()> { ) -> eyre::Result<()> {
remove_file_if_exists(&game_root.join(VERSION_TMP_FILE)).await?; game_root
let version_path = game_root.join(VERSION_INI); .remove_root_file_if_exists(VERSION_TMP_FILE)
let discarded_path = game_root.join(VERSION_DISCARDED_FILE); .await?;
if path_is_regular_file(&version_path).await? { if game_root.root_regular_file_exists(VERSION_INI).await? {
remove_file_if_exists(&discarded_path).await?; game_root
sync_game_root(game_root)?; .remove_root_file_if_exists(VERSION_DISCARDED_FILE)
.await?;
return Ok(()); return Ok(());
} }
if path_is_regular_file(&discarded_path).await? { if game_root
tokio::fs::rename(&discarded_path, &version_path).await?; .root_regular_file_exists(VERSION_DISCARDED_FILE)
sync_parent_dir(&version_path)?; .await?
{
game_root
.rename_root_file(VERSION_DISCARDED_FILE, VERSION_INI)
.await?;
game_root.sync_root().await?;
} }
Ok(()) Ok(())
} }
/// Removes all sentinel scratch after an aborted journaled download. /// Removes all sentinel scratch after an aborted journaled download.
pub(super) async fn discard_version_ini_transaction(game_root: &Path) -> eyre::Result<()> { pub(super) async fn discard_version_ini_transaction(
remove_file_if_exists(&game_root.join(VERSION_TMP_FILE)).await?; game_root: &ConfinedGameRoot,
remove_file_if_exists(&game_root.join(VERSION_DISCARDED_FILE)).await?; ) -> eyre::Result<()> {
sync_game_root_if_exists(game_root)?; 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(()) Ok(())
} }
/// Sweeps scratch left after a committed sentinel was recovered. /// 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 discard_version_ini_transaction(game_root).await
} }
pub(super) async fn commit_version_ini_buffer( pub(super) async fn commit_version_ini_buffer(
game_root: &Path, game_root: &ConfinedGameRoot,
buffer: &VersionIniBuffer, buffer: &VersionIniBuffer,
) -> eyre::Result<VersionIniCommit> { ) -> 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( async fn commit_version_ini_buffer_with_sync(
game_root: &Path, game_root: &ConfinedGameRoot,
buffer: &VersionIniBuffer, buffer: &VersionIniBuffer,
mut sync_root: impl FnMut(&Path) -> std::io::Result<()>, injected_sync_error: Option<std::io::Error>,
) -> eyre::Result<VersionIniCommit> { ) -> 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 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.write_all(&bytes).await?;
file.sync_all().await?; file.sync_all().await?;
drop(file); drop(file);
tokio::fs::rename(&tmp_path, &version_path).await?; game_root
if let Err(error) = sync_root(game_root) { .rename_root_file(VERSION_TMP_FILE, VERSION_INI)
.await?;
if let Some(error) = injected_sync_error {
return Ok(VersionIniCommit::NeedsRecovery(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!( log::warn!(
"Committed {} but failed to sweep the parked sentinel: {error}", "Committed {} but failed to sweep the parked sentinel: {error}",
version_path.display() game_root.display_path().join(VERSION_INI).display()
);
}
if let Err(error) = sync_root(game_root) {
log::warn!(
"Committed {} but failed to sync discarded-sentinel cleanup: {error}",
version_path.display()
); );
} }
Ok(VersionIniCommit::Durable) 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)] #[cfg(test)]
mod tests { mod tests {
use lanspread_db::db::GameFileDescription;
use super::*; use super::*;
use crate::test_support::TempDir; 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] #[tokio::test]
async fn version_ini_buffer_accepts_out_of_order_chunks() { async fn version_ini_buffer_accepts_out_of_order_chunks() {
let desc = GameFileDescription { let buffer =
game_id: "game".to_string(), VersionIniBuffer::new("game/version.ini", 8).expect("buffer should be created");
relative_path: "game/version.ini".to_string(),
is_dir: false,
size: 8,
};
let buffer = VersionIniBuffer::new(&desc).expect("buffer should be created");
buffer buffer
.write_at(4, b"0101") .write_at(4, b"0101")
@@ -228,21 +197,13 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn commit_version_ini_writes_sentinel_last_and_sweeps_discarded() { async fn commit_version_ini_writes_sentinel_last_and_sweeps_discarded() {
let temp = TempDir::new("lanspread-download"); let temp = TempDir::new("lanspread-download");
let game_root = temp.path().join("game"); let game_root = open_game_root(&temp).await;
tokio::fs::create_dir_all(&game_root) tokio::fs::write(temp.game_root().join(".version.ini.discarded"), b"old")
.await
.expect("game root should be created");
tokio::fs::write(game_root.join(".version.ini.discarded"), b"old")
.await .await
.expect("discarded sentinel should be written"); .expect("discarded sentinel should be written");
let desc = GameFileDescription { let buffer =
game_id: "game".to_string(), VersionIniBuffer::new("game/version.ini", 8).expect("buffer should be created");
relative_path: "game/version.ini".to_string(),
is_dir: false,
size: 8,
};
let buffer = VersionIniBuffer::new(&desc).expect("buffer should be created");
buffer buffer
.write_at(0, b"20250101") .write_at(0, b"20250101")
.await .await
@@ -253,62 +214,53 @@ mod tests {
.expect("version sentinel should commit"); .expect("version sentinel should commit");
assert_eq!( 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" b"20250101"
); );
assert!(!game_root.join(".version.ini.tmp").exists()); assert!(!temp.game_root().join(".version.ini.tmp").exists());
assert!(!game_root.join(".version.ini.discarded").exists()); assert!(!temp.game_root().join(".version.ini.discarded").exists());
} }
#[tokio::test] #[tokio::test]
async fn landed_rename_with_failed_parent_sync_keeps_recovery_state() { async fn landed_rename_with_failed_parent_sync_keeps_recovery_state() {
let temp = TempDir::new("lanspread-version-durability"); let temp = TempDir::new("lanspread-version-durability");
let game_root = temp.game_root(); let game_root = open_game_root(&temp).await;
tokio::fs::create_dir_all(&game_root) tokio::fs::write(temp.game_root().join(VERSION_DISCARDED_FILE), b"20240101")
.await
.expect("game root should be created");
tokio::fs::write(game_root.join(VERSION_DISCARDED_FILE), b"20240101")
.await .await
.expect("old sentinel should be parked"); .expect("old sentinel should be parked");
let desc = GameFileDescription { let buffer =
game_id: "game".to_string(), VersionIniBuffer::new("game/version.ini", 8).expect("buffer should be created");
relative_path: "game/version.ini".to_string(),
is_dir: false,
size: 8,
};
let buffer = VersionIniBuffer::new(&desc).expect("buffer should be created");
buffer buffer
.write_at(0, b"20250101") .write_at(0, b"20250101")
.await .await
.expect("sentinel bytes should be buffered"); .expect("sentinel bytes should be buffered");
let outcome = commit_version_ini_buffer_with_sync(&game_root, &buffer, |_| { let outcome = commit_version_ini_buffer_with_sync(
Err(std::io::Error::other("injected directory sync failure")) &game_root,
}) &buffer,
Some(std::io::Error::other("injected directory sync failure")),
)
.await .await
.expect("the landed rename should be reported as recoverable"); .expect("the landed rename should be reported as recoverable");
assert!(matches!(outcome, VersionIniCommit::NeedsRecovery(_))); assert!(matches!(outcome, VersionIniCommit::NeedsRecovery(_)));
assert_eq!( assert_eq!(
tokio::fs::read(game_root.join(VERSION_INI)) tokio::fs::read(temp.game_root().join(VERSION_INI))
.await .await
.expect("new sentinel should be visible"), .expect("new sentinel should be visible"),
b"20250101" b"20250101"
); );
assert!(game_root.join(VERSION_DISCARDED_FILE).is_file()); assert!(temp.game_root().join(VERSION_DISCARDED_FILE).is_file());
} }
#[tokio::test] #[tokio::test]
async fn begin_version_ini_transaction_parks_existing_sentinel() { async fn begin_version_ini_transaction_parks_existing_sentinel() {
let temp = TempDir::new("lanspread-download"); let temp = TempDir::new("lanspread-download");
let game_root = temp.path().join("game"); let game_root = open_game_root(&temp).await;
tokio::fs::create_dir_all(&game_root) tokio::fs::write(temp.game_root().join("version.ini"), b"20240101")
.await
.expect("game root should be created");
tokio::fs::write(game_root.join("version.ini"), b"20240101")
.await .await
.expect("version sentinel should be written"); .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 .await
.expect("tmp sentinel should be written"); .expect("tmp sentinel should be written");
@@ -316,32 +268,51 @@ mod tests {
.await .await
.expect("transaction should begin"); .expect("transaction should begin");
assert!(!game_root.join("version.ini").exists()); assert!(!temp.game_root().join("version.ini").exists());
assert!(!game_root.join(".version.ini.tmp").exists()); assert!(!temp.game_root().join(".version.ini.tmp").exists());
assert_eq!( 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"), .expect("discarded sentinel should exist"),
b"20240101" 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] #[tokio::test]
async fn rollback_version_ini_transaction_sweeps_transients() { async fn rollback_version_ini_transaction_sweeps_transients() {
let temp = TempDir::new("lanspread-download"); let temp = TempDir::new("lanspread-download");
let game_root = temp.path().join("game"); let game_root = open_game_root(&temp).await;
tokio::fs::create_dir_all(&game_root) tokio::fs::write(temp.game_root().join(".version.ini.tmp"), b"partial")
.await
.expect("game root should be created");
tokio::fs::write(game_root.join(".version.ini.tmp"), b"partial")
.await .await
.expect("tmp sentinel should be written"); .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 .await
.expect("discarded sentinel should be written"); .expect("discarded sentinel should be written");
rollback_version_ini_transaction(&game_root).await; rollback_version_ini_transaction(&game_root).await;
assert!(!game_root.join(".version.ini.tmp").exists()); assert!(!temp.game_root().join(".version.ini.tmp").exists());
assert!(!game_root.join(".version.ini.discarded").exists()); assert!(!temp.game_root().join(".version.ini.discarded").exists());
} }
} }
@@ -1215,7 +1215,7 @@ mod tests {
for (id, intent_state, has_backup) in cases { for (id, intent_state, has_backup) in cases {
let temp = TempDir::new("lanspread-install"); 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("version.ini"), b"20250101");
write_file(&root.join(LOCAL_DIR).join("payload.txt"), LOCAL_PAYLOAD); write_file(&root.join(LOCAL_DIR).join("payload.txt"), LOCAL_PAYLOAD);
if has_backup { if has_backup {
@@ -140,25 +140,27 @@ Alternatives:
**TL;DR:** Once an ownership-record temporary file has been synced and renamed, **TL;DR:** Once an ownership-record temporary file has been synced and renamed,
the application treats it as published. A subsequent parent-directory sync 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 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 visible pending record. Recovery would then mistake that old sentinel for the
new download's commit point. Treating rename as publication keeps every new download's commit point. Continuing after a failed directory sync is also
observable state unambiguous; the parent sync still runs to improve power-loss unsafe: power loss could retain the previous baseline record and later restore
durability. the old sentinel over mutated payload. The phase-aware result keeps every
observable state unambiguous and prevents both mistakes.
Alternatives: Alternatives:
- Return a phase-aware error that forces every caller to distinguish failures - Log the post-rename sync failure and continue. This avoids disrupting a
before and after rename. This is explicit, but spreads a subtle transaction download for a rare filesystem error, but permits mutation without a durable
protocol across all ownership callers without changing their post-rename write-ahead record.
action.
- Try to rename the old record back after a sync failure. That adds another - 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. 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 - Return one ordinary error for every failure. This is simpler, but callers
sounds stricter, but callers could then perform the unsafe sentinel rollback cannot tell whether restoring the old sentinel is safe after the canonical
unless the error also carries publication state. record name became visible.
## 2026-08-09 — Reject cross-version portable path aliases ## 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 - Introduce another dedicated phase-marker file. This is equally expressive, but
adds a second persistent transaction artifact where an empty valid ledger adds a second persistent transaction artifact where an empty valid ledger
already provides the needed proof. 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.