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:
Generated
+96
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(¤t, 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(¤t, component, create)?;
|
||||||
|
}
|
||||||
|
unreachable!("validated paths contain one component")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_owned_regular_file_blocking(&self, path: &ValidatedDownloadPath) -> eyre::Result<()> {
|
||||||
|
let Some((parent, leaf)) = self.open_parent_for_cleanup_blocking(path)? else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let metadata = match fs::stat(&parent, Path::new(leaf), FollowSymlinks::No) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||||
|
log::warn!(
|
||||||
|
"Preserving owned path whose shape changed at {}/{}",
|
||||||
|
self.inner.display_path.display(),
|
||||||
|
path.canonical()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening and inspecting the same object before unlink catches Windows
|
||||||
|
// reparse points that are not reported as ordinary symlinks.
|
||||||
|
let file = match inspect_regular_file_at(&parent, leaf) {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(error) if is_preservable_shape_error(&error) => {
|
||||||
|
log::warn!(
|
||||||
|
"Preserving owned path whose final object changed at {}/{}",
|
||||||
|
self.inner.display_path.display(),
|
||||||
|
path.canonical()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
drop(file);
|
||||||
|
fs::remove_file(&parent, Path::new(leaf))?;
|
||||||
|
sync_directory_handle(&parent)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_parent_for_cleanup_blocking<'a>(
|
||||||
|
&self,
|
||||||
|
path: &'a ValidatedDownloadPath,
|
||||||
|
) -> eyre::Result<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(¤t, Path::new(component), FollowSymlinks::No) {
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||||
|
log::warn!(
|
||||||
|
"Preserving owned path with a changed parent at {}/{}",
|
||||||
|
self.inner.display_path.display(),
|
||||||
|
path.canonical()
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let directory = match fs::open(¤t, Path::new(component), &directory_options()) {
|
||||||
|
Ok(directory) => directory,
|
||||||
|
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
};
|
||||||
|
if let Err(error) = validate_directory_handle(&directory, component) {
|
||||||
|
if error.kind() == ErrorKind::InvalidInput {
|
||||||
|
log::warn!(
|
||||||
|
"Preserving owned path with a changed parent at {}/{}",
|
||||||
|
self.inner.display_path.display(),
|
||||||
|
path.canonical()
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
return Err(error.into());
|
||||||
|
}
|
||||||
|
current = directory;
|
||||||
|
}
|
||||||
|
unreachable!("validated paths contain one component")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_ambient_directory_nofollow(path: &Path) -> eyre::Result<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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user