feat(peer)!: cut over to authenticated catalog sharing
Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed
This commit is contained in:
128 files changed
+51759
-10784
No files matched your search
@@ -19,8 +19,10 @@ use cap_primitives::{
|
||||
ambient_authority,
|
||||
fs::{self, DirOptions, OpenOptions},
|
||||
};
|
||||
use lanspread_db::content_manifest::CanonicalCatalogPath;
|
||||
|
||||
use super::manifest::{ValidatedDownloadEntry, ValidatedDownloadPath};
|
||||
use crate::scoped_blocking::scoped_blocking;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct ConfinedGameRoot {
|
||||
@@ -42,33 +44,26 @@ impl fmt::Debug for ConfinedGameRoot {
|
||||
}
|
||||
|
||||
impl ConfinedGameRoot {
|
||||
pub(super) async fn open_or_create(games_folder: &Path, game_id: &str) -> eyre::Result<Self> {
|
||||
pub(super) 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?
|
||||
scoped_blocking(move || Self::open_blocking(&games_folder, &game_id, true))
|
||||
}
|
||||
|
||||
pub(super) async fn open_existing(
|
||||
games_folder: &Path,
|
||||
game_id: &str,
|
||||
) -> eyre::Result<Option<Self>> {
|
||||
pub(super) 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),
|
||||
})
|
||||
match scoped_blocking(move || Self::open_blocking(&games_folder, &game_id, false)) {
|
||||
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> {
|
||||
@@ -100,12 +95,9 @@ impl ConfinedGameRoot {
|
||||
&self.inner.display_path
|
||||
}
|
||||
|
||||
pub(super) async fn prepare_entries(
|
||||
&self,
|
||||
entries: Vec<ValidatedDownloadEntry>,
|
||||
) -> eyre::Result<()> {
|
||||
pub(super) fn prepare_entries(&self, entries: Vec<ValidatedDownloadEntry>) -> eyre::Result<()> {
|
||||
let root = self.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
scoped_blocking(move || {
|
||||
for entry in &entries {
|
||||
if entry.is_dir() {
|
||||
root.open_directory_blocking(entry.destination(), true)?;
|
||||
@@ -115,21 +107,39 @@ impl ConfinedGameRoot {
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
pub(super) async fn open_chunk_file(&self, path: &ValidatedDownloadPath) -> eyre::Result<File> {
|
||||
pub(super) 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?
|
||||
scoped_blocking(move || root.open_regular_file_blocking(&path, false))
|
||||
}
|
||||
|
||||
pub(super) async fn sync_entries(
|
||||
fn open_catalog_file_for_read(
|
||||
&self,
|
||||
entries: Vec<ValidatedDownloadEntry>,
|
||||
) -> eyre::Result<()> {
|
||||
path: &CanonicalCatalogPath,
|
||||
expected_size: u64,
|
||||
) -> eyre::Result<File> {
|
||||
let root = self.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let path = path.clone();
|
||||
scoped_blocking(move || {
|
||||
let (parent, leaf) = root.open_parent_from_canonical_blocking(path.as_str(), false)?;
|
||||
let file = open_regular_file_for_read_at(&parent, leaf)?;
|
||||
let actual_size = file.metadata()?.len();
|
||||
if actual_size != expected_size {
|
||||
eyre::bail!(
|
||||
"catalog file size mismatch at {}/{}: expected {expected_size}, found {actual_size}",
|
||||
root.inner.display_path.display(),
|
||||
path.as_str()
|
||||
);
|
||||
}
|
||||
Ok(file)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn sync_entries(&self, entries: Vec<ValidatedDownloadEntry>) -> eyre::Result<()> {
|
||||
let root = self.clone();
|
||||
scoped_blocking(move || {
|
||||
let mut parent_directories = BTreeSet::new();
|
||||
for entry in &entries {
|
||||
if !entry.is_dir() {
|
||||
@@ -148,40 +158,37 @@ impl ConfinedGameRoot {
|
||||
sync_directory_handle(&root.inner.game_root)?;
|
||||
Ok(())
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
pub(super) async fn remove_owned_regular_files(
|
||||
pub(super) fn remove_owned_regular_files(
|
||||
&self,
|
||||
paths: Vec<ValidatedDownloadPath>,
|
||||
) -> eyre::Result<()> {
|
||||
let root = self.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
scoped_blocking(move || {
|
||||
for path in &paths {
|
||||
root.remove_owned_regular_file_blocking(path)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
pub(super) async fn reject_existing_unowned_files(
|
||||
pub(super) fn reject_existing_unowned_files(
|
||||
&self,
|
||||
paths: Vec<ValidatedDownloadPath>,
|
||||
) -> eyre::Result<()> {
|
||||
let root = self.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
scoped_blocking(move || {
|
||||
for path in &paths {
|
||||
root.reject_existing_unowned_file_blocking(path)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
pub(super) async fn root_regular_file_exists(&self, name: &'static str) -> eyre::Result<bool> {
|
||||
pub(super) fn root_regular_file_exists(&self, name: &'static str) -> eyre::Result<bool> {
|
||||
let root = self.clone();
|
||||
tokio::task::spawn_blocking(
|
||||
scoped_blocking(
|
||||
move || match root.inspect_root_regular_file_blocking(name) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(error)
|
||||
@@ -194,29 +201,35 @@ impl ConfinedGameRoot {
|
||||
Err(error) => Err(error),
|
||||
},
|
||||
)
|
||||
.await?
|
||||
}
|
||||
|
||||
pub(super) async fn root_entry_exists(&self, name: &'static str) -> eyre::Result<bool> {
|
||||
pub(super) fn root_entry_exists(&self, name: &'static str) -> eyre::Result<bool> {
|
||||
let root = self.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
scoped_blocking(move || {
|
||||
match fs::stat(&root.inner.game_root, Path::new(name), FollowSymlinks::No) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
pub(super) async fn create_new_root_file(&self, name: &'static str) -> eyre::Result<File> {
|
||||
pub(super) 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?
|
||||
scoped_blocking(move || root.create_new_root_file_blocking(name))
|
||||
}
|
||||
|
||||
pub(super) async fn remove_root_file_if_exists(&self, name: &'static str) -> eyre::Result<()> {
|
||||
pub(super) fn remove_root_file_if_exists(&self, name: &'static str) -> eyre::Result<()> {
|
||||
let root = self.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
scoped_blocking(move || {
|
||||
// An unlink attempt against a missing entry on a read-only mount
|
||||
// fails with EROFS rather than NotFound. Inspect first so passive
|
||||
// startup recovery can leave a complete read-only package alone.
|
||||
match fs::stat(&root.inner.game_root, Path::new(name), FollowSymlinks::No) {
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
#[cfg(windows)]
|
||||
match root.inspect_root_regular_file_blocking(name) {
|
||||
Ok(file) => {
|
||||
@@ -240,16 +253,15 @@ impl ConfinedGameRoot {
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
pub(super) async fn rename_root_file(
|
||||
pub(super) fn rename_root_file(
|
||||
&self,
|
||||
source: &'static str,
|
||||
destination: &'static str,
|
||||
) -> eyre::Result<()> {
|
||||
let root = self.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
scoped_blocking(move || {
|
||||
fs::rename(
|
||||
&root.inner.game_root,
|
||||
Path::new(source),
|
||||
@@ -258,14 +270,11 @@ impl ConfinedGameRoot {
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
pub(super) async fn sync_root(&self) -> std::io::Result<()> {
|
||||
pub(super) 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)?
|
||||
scoped_blocking(move || sync_directory_handle(&root.inner.game_root))
|
||||
}
|
||||
|
||||
fn open_directory_blocking(
|
||||
@@ -316,7 +325,15 @@ impl ConfinedGameRoot {
|
||||
path: &'a ValidatedDownloadPath,
|
||||
create: bool,
|
||||
) -> eyre::Result<(File, &'a str)> {
|
||||
let mut components = path.components().peekable();
|
||||
self.open_parent_from_canonical_blocking(path.canonical(), create)
|
||||
}
|
||||
|
||||
fn open_parent_from_canonical_blocking<'a>(
|
||||
&self,
|
||||
canonical_path: &'a str,
|
||||
create: bool,
|
||||
) -> eyre::Result<(File, &'a str)> {
|
||||
let mut components = canonical_path.split('/').peekable();
|
||||
let mut current = self.inner.game_root.try_clone()?;
|
||||
while let Some(component) = components.next() {
|
||||
if components.peek().is_none() {
|
||||
@@ -445,6 +462,20 @@ impl ConfinedGameRoot {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens one catalog-authorized ordinary file through retained, no-follow
|
||||
/// directory handles and verifies that the opened object still has the exact
|
||||
/// catalog size.
|
||||
pub(crate) fn open_catalog_file_for_read(
|
||||
games_folder: &Path,
|
||||
game_id: &str,
|
||||
path: &CanonicalCatalogPath,
|
||||
expected_size: u64,
|
||||
) -> eyre::Result<File> {
|
||||
let root = ConfinedGameRoot::open_existing(games_folder, game_id)?
|
||||
.ok_or_else(|| eyre::eyre!("catalog game root does not exist: {game_id}"))?;
|
||||
root.open_catalog_file_for_read(path, expected_size)
|
||||
}
|
||||
|
||||
fn open_ambient_directory_nofollow(path: &Path) -> eyre::Result<File> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.read(true);
|
||||
@@ -483,6 +514,12 @@ fn open_regular_file_at(parent: &File, leaf: &str, create: bool) -> eyre::Result
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn open_regular_file_for_read_at(parent: &File, leaf: &str) -> eyre::Result<File> {
|
||||
let file = fs::open(parent, Path::new(leaf), &inspection_file_options())?;
|
||||
validate_regular_file_handle(&file, leaf)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn inspect_regular_file_at(parent: &File, leaf: &str) -> eyre::Result<File> {
|
||||
let options = inspection_file_options();
|
||||
@@ -602,7 +639,7 @@ const fn sync_directory_handle(_directory: &File) -> std::io::Result<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::TempDir;
|
||||
@@ -611,19 +648,17 @@ mod tests {
|
||||
ValidatedDownloadPath::from_ownership(value).expect("test path should validate")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepares_and_reopens_nested_regular_file() {
|
||||
#[test]
|
||||
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 root =
|
||||
ConfinedGameRoot::open_or_create(games.path(), "game").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");
|
||||
@@ -636,16 +671,102 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_read_opens_only_the_exact_sized_regular_file() {
|
||||
let games = TempDir::new("lanspread-confined-catalog-read");
|
||||
std::fs::create_dir_all(games.game_root()).expect("game root should be created");
|
||||
std::fs::write(games.game_root().join("version.ini"), b"20250101")
|
||||
.expect("version sentinel should be written");
|
||||
let path =
|
||||
CanonicalCatalogPath::new("version.ini").expect("catalog path should be canonical");
|
||||
|
||||
let mut file = open_catalog_file_for_read(games.path(), "game", &path, 8)
|
||||
.expect("exact catalog file should open");
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)
|
||||
.expect("opened catalog file should be readable");
|
||||
assert_eq!(bytes, b"20250101");
|
||||
assert!(
|
||||
file.write_all(b"mutation").is_err(),
|
||||
"sender capability must be read-only"
|
||||
);
|
||||
assert!(open_catalog_file_for_read(games.path(), "game", &path, 7).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn intermediate_and_final_symlinks_never_redirect_preparation() {
|
||||
#[test]
|
||||
fn catalog_read_rejects_intermediate_and_final_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let games = TempDir::new("lanspread-confined-catalog-read-links");
|
||||
let outside = TempDir::new("lanspread-confined-catalog-read-outside");
|
||||
std::fs::create_dir_all(games.game_root()).expect("game root should be created");
|
||||
std::fs::write(outside.path().join("canary"), b"outside")
|
||||
.expect("outside file should be written");
|
||||
symlink(outside.path(), games.game_root().join("linked"))
|
||||
.expect("intermediate link should be created");
|
||||
symlink(
|
||||
outside.path().join("canary"),
|
||||
games.game_root().join("leaf"),
|
||||
)
|
||||
.expect("final link should be created");
|
||||
|
||||
let intermediate =
|
||||
CanonicalCatalogPath::new("linked/canary").expect("catalog path should be canonical");
|
||||
let final_link =
|
||||
CanonicalCatalogPath::new("leaf").expect("catalog path should be canonical");
|
||||
assert!(open_catalog_file_for_read(games.path(), "game", &intermediate, 7).is_err());
|
||||
assert!(open_catalog_file_for_read(games.path(), "game", &final_link, 7).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn catalog_read_retains_the_opened_file_after_a_path_swap() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let games = TempDir::new("lanspread-confined-catalog-read-swap");
|
||||
let outside = TempDir::new("lanspread-confined-catalog-read-swap-outside");
|
||||
std::fs::create_dir_all(games.game_root()).expect("game root should be created");
|
||||
std::fs::write(games.game_root().join("payload.bin"), b"catalog")
|
||||
.expect("catalog payload should be written");
|
||||
std::fs::write(outside.path().join("canary"), b"outside")
|
||||
.expect("outside file should be written");
|
||||
let path =
|
||||
CanonicalCatalogPath::new("payload.bin").expect("catalog path should be canonical");
|
||||
let mut file = open_catalog_file_for_read(games.path(), "game", &path, 7)
|
||||
.expect("catalog file should open");
|
||||
|
||||
std::fs::rename(
|
||||
games.game_root().join("payload.bin"),
|
||||
games.game_root().join("original.bin"),
|
||||
)
|
||||
.expect("catalog payload should move");
|
||||
symlink(
|
||||
outside.path().join("canary"),
|
||||
games.game_root().join("payload.bin"),
|
||||
)
|
||||
.expect("replacement link should be created");
|
||||
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)
|
||||
.expect("retained handle should remain readable");
|
||||
assert_eq!(bytes, b"catalog");
|
||||
assert_eq!(
|
||||
std::fs::read(outside.path().join("canary"))
|
||||
.expect("outside canary should remain readable"),
|
||||
b"outside"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
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");
|
||||
let root =
|
||||
ConfinedGameRoot::open_or_create(games.path(), "game").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"))
|
||||
@@ -669,21 +790,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn writes_remain_on_open_handle_after_path_swap() {
|
||||
#[test]
|
||||
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 root =
|
||||
ConfinedGameRoot::open_or_create(games.path(), "game").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(
|
||||
@@ -715,15 +834,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn retained_root_handle_survives_ambient_path_swap() {
|
||||
#[test]
|
||||
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");
|
||||
let root =
|
||||
ConfinedGameRoot::open_or_create(games.path(), "game").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");
|
||||
@@ -741,42 +859,38 @@ mod tests {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn cleanup_can_inspect_and_remove_read_only_owned_files() {
|
||||
#[test]
|
||||
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 root =
|
||||
ConfinedGameRoot::open_or_create(games.path(), "game").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());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn cleanup_does_not_require_read_access_to_owned_files() {
|
||||
#[test]
|
||||
fn cleanup_does_not_require_read_access_to_owned_files() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let games = TempDir::new("lanspread-confined-mode-zero-cleanup");
|
||||
let root = ConfinedGameRoot::open_or_create(games.path(), "game")
|
||||
.await
|
||||
.expect("game root should open");
|
||||
let root =
|
||||
ConfinedGameRoot::open_or_create(games.path(), "game").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(0o000))
|
||||
.expect("payload permissions should be removed");
|
||||
|
||||
root.remove_owned_regular_files(vec![path("payload.bin")])
|
||||
.await
|
||||
.expect("mode-zero owned file should be removable by its parent owner");
|
||||
|
||||
assert!(!payload.exists());
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fmt,
|
||||
fs::Metadata,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use eyre::WrapErr;
|
||||
use lanspread_db::db::{GameCatalog, GameFileDescription};
|
||||
use lanspread_db::content_manifest::{
|
||||
Blake3Digest,
|
||||
CanonicalCatalogPath,
|
||||
CatalogContentManifest,
|
||||
CatalogEntryKind,
|
||||
ContentId,
|
||||
};
|
||||
use unicode_normalization::is_nfc;
|
||||
|
||||
use crate::game_paths::{VERSION_INI, is_download_protected_root_name, portable_name_key};
|
||||
|
||||
/// A remote manifest may describe at most this many filesystem entries.
|
||||
pub(crate) const MAX_DOWNLOAD_MANIFEST_ENTRIES: usize = 100_000;
|
||||
/// A single remotely described file may be at most one tebibyte.
|
||||
pub(crate) const MAX_DOWNLOAD_FILE_BYTES: u64 = 1024 * 1024 * 1024 * 1024;
|
||||
/// A complete remotely described game may be at most sixteen tebibytes.
|
||||
pub(crate) const MAX_DOWNLOAD_MANIFEST_BYTES: u64 = 16 * MAX_DOWNLOAD_FILE_BYTES;
|
||||
/// The root sentinel is parsed in memory and should contain only a version value.
|
||||
pub(crate) const MAX_VERSION_INI_BYTES: u64 = 64 * 1024;
|
||||
/// Portable filesystems support at least 255 bytes per ordinary component.
|
||||
pub(crate) const MAX_DOWNLOAD_COMPONENT_BYTES: usize = 255;
|
||||
/// Leave room for the configured game root under conservative 1,024-unit paths.
|
||||
@@ -28,9 +29,9 @@ const MAX_DOWNLOAD_DESTINATION_UNITS: usize = 1_000;
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ValidatedDownloadEntry {
|
||||
destination: ValidatedDownloadPath,
|
||||
protocol_path: String,
|
||||
is_dir: bool,
|
||||
size: u64,
|
||||
catalog_file_index: usize,
|
||||
}
|
||||
|
||||
impl ValidatedDownloadEntry {
|
||||
@@ -38,10 +39,6 @@ impl ValidatedDownloadEntry {
|
||||
&self.destination
|
||||
}
|
||||
|
||||
pub(super) fn protocol_path(&self) -> &str {
|
||||
&self.protocol_path
|
||||
}
|
||||
|
||||
pub(crate) const fn is_dir(&self) -> bool {
|
||||
self.is_dir
|
||||
}
|
||||
@@ -53,50 +50,79 @@ impl ValidatedDownloadEntry {
|
||||
pub(crate) fn is_version_ini(&self) -> bool {
|
||||
self.destination.canonical() == VERSION_INI
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn protocol_description(&self, game_id: &str) -> GameFileDescription {
|
||||
GameFileDescription {
|
||||
game_id: game_id.to_owned(),
|
||||
relative_path: self.protocol_path.clone(),
|
||||
is_dir: self.is_dir,
|
||||
size: self.size,
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum CatalogDigestSource {
|
||||
File,
|
||||
Chunk(usize),
|
||||
}
|
||||
|
||||
/// A constant-size reference to one digest retained by the catalog authority.
|
||||
///
|
||||
/// Planning clones only the manifest `Arc` and these indices, not the catalog's
|
||||
/// potentially millions of 32-byte digest values.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct ExpectedCatalogBlake3 {
|
||||
manifest: Arc<CatalogContentManifest>,
|
||||
file_index: usize,
|
||||
source: CatalogDigestSource,
|
||||
}
|
||||
|
||||
impl ExpectedCatalogBlake3 {
|
||||
pub(super) fn digest(&self) -> Blake3Digest {
|
||||
let file = self
|
||||
.manifest
|
||||
.files()
|
||||
.get(self.file_index)
|
||||
.expect("validated catalog digest references retain their file entry");
|
||||
match self.source {
|
||||
CatalogDigestSource::File => file
|
||||
.file_blake3()
|
||||
.expect("validated catalog regular files retain their file digest"),
|
||||
CatalogDigestSource::Chunk(index) => file.chunk_blake3()[index],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
impl fmt::Debug for ExpectedCatalogBlake3 {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let file = &self.manifest.files()[self.file_index];
|
||||
formatter
|
||||
.debug_struct("ExpectedCatalogBlake3")
|
||||
.field("content_id", &self.manifest.content_id())
|
||||
.field("path", &file.canonical_path().as_str())
|
||||
.field("source", &self.source)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A canonical root-relative path that passed the complete download policy.
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub(super) struct ValidatedDownloadPath {
|
||||
canonical: String,
|
||||
canonical: CanonicalCatalogPath,
|
||||
}
|
||||
|
||||
impl ValidatedDownloadPath {
|
||||
fn new(canonical: String) -> Self {
|
||||
fn new(canonical: CanonicalCatalogPath) -> Self {
|
||||
Self { canonical }
|
||||
}
|
||||
|
||||
pub(super) fn from_ownership(path: &str) -> eyre::Result<Self> {
|
||||
validate_owned_file_path(path)?;
|
||||
Ok(Self::new(path.to_owned()))
|
||||
Ok(Self::new(CanonicalCatalogPath::new(path)?))
|
||||
}
|
||||
|
||||
pub(super) fn canonical(&self) -> &str {
|
||||
self.canonical.as_str()
|
||||
}
|
||||
|
||||
pub(super) const fn catalog_path(&self) -> &CanonicalCatalogPath {
|
||||
&self.canonical
|
||||
}
|
||||
|
||||
pub(super) fn components(&self) -> impl DoubleEndedIterator<Item = &str> {
|
||||
self.canonical.split('/')
|
||||
self.canonical.as_str().split('/')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,41 +132,55 @@ pub(crate) struct ValidatedDownloadManifest {
|
||||
game_id: String,
|
||||
games_folder: PathBuf,
|
||||
entries: Vec<ValidatedDownloadEntry>,
|
||||
catalog: Arc<CatalogContentManifest>,
|
||||
}
|
||||
|
||||
impl ValidatedDownloadManifest {
|
||||
/// Contains current protocol-7 descriptions within one known catalog game root.
|
||||
pub(crate) fn from_protocol_v7(
|
||||
/// Builds the complete ordinary-download plan exclusively from the local
|
||||
/// catalog authority.
|
||||
pub(crate) fn from_catalog(
|
||||
games_folder: &Path,
|
||||
game_id: &str,
|
||||
descriptions: Vec<GameFileDescription>,
|
||||
catalog: &GameCatalog,
|
||||
catalog: Arc<CatalogContentManifest>,
|
||||
) -> eyre::Result<Self> {
|
||||
if !catalog.contains(game_id) {
|
||||
eyre::bail!("cannot download unknown catalog game {game_id}");
|
||||
}
|
||||
if descriptions.len() > MAX_DOWNLOAD_MANIFEST_ENTRIES {
|
||||
let game_id = catalog.game_id().to_owned();
|
||||
validate_game_id(&game_id)?;
|
||||
let games_folder = canonical_games_folder(games_folder)?;
|
||||
let game_root = games_folder.join(&game_id);
|
||||
validate_game_root(&game_root)?;
|
||||
if catalog.files().len() > MAX_DOWNLOAD_MANIFEST_ENTRIES {
|
||||
eyre::bail!(
|
||||
"download manifest for {game_id} has {} entries; limit is {MAX_DOWNLOAD_MANIFEST_ENTRIES}",
|
||||
descriptions.len()
|
||||
"catalog manifest for {game_id} has {} entries; limit is {MAX_DOWNLOAD_MANIFEST_ENTRIES}",
|
||||
catalog.files().len()
|
||||
);
|
||||
}
|
||||
|
||||
validate_game_id(game_id)?;
|
||||
let games_folder = canonical_games_folder(games_folder)?;
|
||||
let game_root = games_folder.join(game_id);
|
||||
validate_game_root(&game_root)?;
|
||||
let mut builder =
|
||||
ProtocolV7ManifestBuilder::new(game_id, Some(&game_root), descriptions.len())?;
|
||||
for description in descriptions {
|
||||
builder.push(description)?;
|
||||
let mut entries = Vec::with_capacity(catalog.files().len());
|
||||
for (catalog_file_index, catalog_entry) in catalog.files().iter().enumerate() {
|
||||
let canonical_catalog_path = catalog_entry.canonical_path().clone();
|
||||
let canonical_path = canonical_catalog_path.as_str();
|
||||
let (_, components) = validate_canonical_path(canonical_path)?;
|
||||
let root_component = components
|
||||
.first()
|
||||
.expect("validated canonical paths have one component");
|
||||
if is_download_protected_root_name(root_component) {
|
||||
eyre::bail!("catalog path targets install or recovery state: {canonical_path}");
|
||||
}
|
||||
|
||||
let is_dir = catalog_entry.kind() == CatalogEntryKind::Directory;
|
||||
validate_existing_destination(&game_root, &components, is_dir, canonical_path)?;
|
||||
entries.push(ValidatedDownloadEntry {
|
||||
destination: ValidatedDownloadPath::new(canonical_catalog_path),
|
||||
is_dir,
|
||||
size: catalog_entry.size(),
|
||||
catalog_file_index,
|
||||
});
|
||||
}
|
||||
let entries = builder.finish()?;
|
||||
|
||||
Ok(Self {
|
||||
game_id: game_id.to_owned(),
|
||||
game_id,
|
||||
games_folder,
|
||||
entries,
|
||||
catalog,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -152,10 +192,47 @@ impl ValidatedDownloadManifest {
|
||||
&self.games_folder
|
||||
}
|
||||
|
||||
pub(crate) fn content_id(&self) -> ContentId {
|
||||
self.catalog.content_id()
|
||||
}
|
||||
|
||||
pub(super) fn entries(&self) -> &[ValidatedDownloadEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
pub(super) fn expected_blake3(
|
||||
&self,
|
||||
entry: &ValidatedDownloadEntry,
|
||||
chunk_index: Option<usize>,
|
||||
) -> eyre::Result<ExpectedCatalogBlake3> {
|
||||
let manifest = &self.catalog;
|
||||
let file_index = entry.catalog_file_index;
|
||||
let catalog_entry = manifest
|
||||
.files()
|
||||
.get(file_index)
|
||||
.ok_or_else(|| eyre::eyre!("catalog file index is out of bounds"))?;
|
||||
let source = if let Some(index) = chunk_index {
|
||||
if catalog_entry.chunk_blake3().get(index).is_none() {
|
||||
eyre::bail!("catalog chunk digest index is out of bounds");
|
||||
}
|
||||
CatalogDigestSource::Chunk(index)
|
||||
} else {
|
||||
if entry.size != 0 || catalog_entry.file_blake3().is_none() {
|
||||
eyre::bail!("only an empty catalog file may use its whole-file digest");
|
||||
}
|
||||
CatalogDigestSource::File
|
||||
};
|
||||
Ok(ExpectedCatalogBlake3 {
|
||||
manifest: Arc::clone(manifest),
|
||||
file_index,
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) const fn catalog_manifest(&self) -> &Arc<CatalogContentManifest> {
|
||||
&self.catalog
|
||||
}
|
||||
|
||||
pub(crate) fn transfer_entries(&self) -> impl Iterator<Item = &ValidatedDownloadEntry> {
|
||||
self.entries.iter().filter(|entry| !entry.is_version_ini())
|
||||
}
|
||||
@@ -170,7 +247,7 @@ impl ValidatedDownloadManifest {
|
||||
pub(super) fn owned_file_paths(&self) -> Vec<String> {
|
||||
self.transfer_entries()
|
||||
.filter(|entry| !entry.is_dir())
|
||||
.map(|entry| entry.destination.canonical.clone())
|
||||
.map(|entry| entry.destination.canonical().to_owned())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -190,199 +267,6 @@ pub(super) fn validate_owned_file_path(path: &str) -> eyre::Result<String> {
|
||||
Ok(alias)
|
||||
}
|
||||
|
||||
/// Validates one peer's complete current-wire description before aggregation.
|
||||
pub(crate) fn validate_protocol_v7_descriptions(
|
||||
game_id: &str,
|
||||
descriptions: Vec<GameFileDescription>,
|
||||
) -> eyre::Result<Vec<GameFileDescription>> {
|
||||
if descriptions.len() > MAX_DOWNLOAD_MANIFEST_ENTRIES {
|
||||
eyre::bail!(
|
||||
"download manifest for {game_id} has {} entries; limit is {MAX_DOWNLOAD_MANIFEST_ENTRIES}",
|
||||
descriptions.len()
|
||||
);
|
||||
}
|
||||
validate_game_id(game_id)?;
|
||||
let mut builder = ProtocolV7ManifestBuilder::new(game_id, None, descriptions.len())?;
|
||||
for description in descriptions {
|
||||
builder.push(description)?;
|
||||
}
|
||||
Ok(builder
|
||||
.finish()?
|
||||
.into_iter()
|
||||
.map(|entry| entry.protocol_description(game_id))
|
||||
.collect())
|
||||
}
|
||||
|
||||
struct ProtocolV7ManifestBuilder<'a> {
|
||||
game_id: &'a str,
|
||||
game_root: Option<&'a Path>,
|
||||
prefix: String,
|
||||
game_alias: String,
|
||||
entries: Vec<ValidatedDownloadEntry>,
|
||||
shapes: BTreeMap<String, EntryShape>,
|
||||
total_bytes: u64,
|
||||
saw_protocol_root: bool,
|
||||
}
|
||||
|
||||
impl<'a> ProtocolV7ManifestBuilder<'a> {
|
||||
fn new(game_id: &'a str, game_root: Option<&'a Path>, capacity: usize) -> eyre::Result<Self> {
|
||||
Ok(Self {
|
||||
game_id,
|
||||
game_root,
|
||||
prefix: format!("{game_id}/"),
|
||||
game_alias: windows_alias_component(game_id)?,
|
||||
entries: Vec::with_capacity(capacity),
|
||||
shapes: BTreeMap::new(),
|
||||
total_bytes: 0,
|
||||
saw_protocol_root: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn push(&mut self, description: GameFileDescription) -> eyre::Result<()> {
|
||||
validate_protocol_game_id(self.game_id, &description)?;
|
||||
if self.take_redundant_root(&description)? {
|
||||
return Ok(());
|
||||
}
|
||||
if description.relative_path.contains('\\') {
|
||||
eyre::bail!(
|
||||
"download path must use forward slashes: {}",
|
||||
description.relative_path
|
||||
);
|
||||
}
|
||||
|
||||
let canonical_path = description
|
||||
.relative_path
|
||||
.strip_prefix(&self.prefix)
|
||||
.ok_or_else(|| {
|
||||
eyre::eyre!(
|
||||
"download path must start with exactly {}: {}",
|
||||
self.prefix,
|
||||
description.relative_path
|
||||
)
|
||||
})?;
|
||||
let (alias_path, components) = validate_canonical_path(canonical_path)?;
|
||||
self.validate_root_component(&components, &description.relative_path)?;
|
||||
validate_entry_shape(
|
||||
&mut self.shapes,
|
||||
&alias_path,
|
||||
description.is_dir,
|
||||
&description.relative_path,
|
||||
)?;
|
||||
self.account_size(canonical_path, &description)?;
|
||||
if let Some(game_root) = self.game_root {
|
||||
validate_existing_destination(
|
||||
game_root,
|
||||
&components,
|
||||
description.is_dir,
|
||||
&description.relative_path,
|
||||
)?;
|
||||
}
|
||||
self.entries.push(ValidatedDownloadEntry {
|
||||
destination: ValidatedDownloadPath::new(canonical_path.to_owned()),
|
||||
protocol_path: description.relative_path,
|
||||
is_dir: description.is_dir,
|
||||
size: description.size,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn take_redundant_root(&mut self, description: &GameFileDescription) -> eyre::Result<bool> {
|
||||
if description.relative_path != self.game_id {
|
||||
return Ok(false);
|
||||
}
|
||||
if description.is_dir && description.size == 0 {
|
||||
if self.saw_protocol_root {
|
||||
eyre::bail!("duplicate protocol game-root entry for {}", self.game_id);
|
||||
}
|
||||
self.saw_protocol_root = true;
|
||||
return Ok(true);
|
||||
}
|
||||
eyre::bail!(
|
||||
"the protocol game-root entry for {} must be a zero-sized directory",
|
||||
self.game_id
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_root_component(&self, components: &[&str], display_path: &str) -> eyre::Result<()> {
|
||||
let root_component = components
|
||||
.first()
|
||||
.expect("validated canonical paths have one component");
|
||||
if windows_alias_component(root_component)? == self.game_alias {
|
||||
eyre::bail!("download path contains a doubled game prefix: {display_path}");
|
||||
}
|
||||
if is_download_protected_root_name(root_component) {
|
||||
eyre::bail!("download path targets install or recovery state: {display_path}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn account_size(
|
||||
&mut self,
|
||||
canonical_path: &str,
|
||||
description: &GameFileDescription,
|
||||
) -> eyre::Result<()> {
|
||||
if description.is_dir {
|
||||
if description.size != 0 {
|
||||
eyre::bail!(
|
||||
"directory entry has a non-zero size: {}",
|
||||
description.relative_path
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if description.size > MAX_DOWNLOAD_FILE_BYTES {
|
||||
eyre::bail!(
|
||||
"download file exceeds the {MAX_DOWNLOAD_FILE_BYTES}-byte limit: {}",
|
||||
description.relative_path
|
||||
);
|
||||
}
|
||||
if canonical_path == VERSION_INI && description.size > MAX_VERSION_INI_BYTES {
|
||||
eyre::bail!(
|
||||
"root version.ini exceeds the {MAX_VERSION_INI_BYTES}-byte limit: {}",
|
||||
description.relative_path
|
||||
);
|
||||
}
|
||||
self.total_bytes = self
|
||||
.total_bytes
|
||||
.checked_add(description.size)
|
||||
.ok_or_else(|| eyre::eyre!("download manifest byte count overflow"))?;
|
||||
if self.total_bytes > MAX_DOWNLOAD_MANIFEST_BYTES {
|
||||
eyre::bail!("download manifest exceeds the {MAX_DOWNLOAD_MANIFEST_BYTES}-byte limit");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(mut self) -> eyre::Result<Vec<ValidatedDownloadEntry>> {
|
||||
self.entries
|
||||
.sort_by(|left, right| left.destination.cmp(&right.destination));
|
||||
let versions = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.is_version_ini())
|
||||
.collect::<Vec<_>>();
|
||||
let [version_ini] = versions.as_slice() else {
|
||||
eyre::bail!(
|
||||
"expected exactly one regular root version.ini for {}, found {}",
|
||||
self.game_id,
|
||||
versions.len()
|
||||
);
|
||||
};
|
||||
if version_ini.is_dir {
|
||||
eyre::bail!(
|
||||
"root version.ini for {} must be a regular file",
|
||||
self.game_id
|
||||
);
|
||||
}
|
||||
Ok(self.entries)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum EntryShape {
|
||||
File,
|
||||
Directory,
|
||||
}
|
||||
|
||||
pub(super) fn validate_game_id(game_id: &str) -> eyre::Result<()> {
|
||||
if game_id.contains('/') || game_id.contains('\\') {
|
||||
eyre::bail!("catalog game ID must be one path component: {game_id}");
|
||||
@@ -397,19 +281,6 @@ pub(super) fn validate_game_id(game_id: &str) -> eyre::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_protocol_game_id(
|
||||
requested_game_id: &str,
|
||||
description: &GameFileDescription,
|
||||
) -> eyre::Result<()> {
|
||||
if description.game_id != requested_game_id {
|
||||
eyre::bail!(
|
||||
"description for {} cannot be used to download {requested_game_id}",
|
||||
description.game_id
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn canonical_games_folder(games_folder: &Path) -> eyre::Result<PathBuf> {
|
||||
if !games_folder.is_absolute() {
|
||||
eyre::bail!(
|
||||
@@ -532,42 +403,6 @@ fn looks_like_dos_short_name(component: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_entry_shape(
|
||||
shapes: &mut BTreeMap<String, EntryShape>,
|
||||
alias_path: &str,
|
||||
is_dir: bool,
|
||||
display_path: &str,
|
||||
) -> eyre::Result<()> {
|
||||
let shape = if is_dir {
|
||||
EntryShape::Directory
|
||||
} else {
|
||||
EntryShape::File
|
||||
};
|
||||
if shapes.insert(alias_path.to_owned(), shape).is_some() {
|
||||
eyre::bail!("duplicate or platform-alias download path: {display_path}");
|
||||
}
|
||||
|
||||
let mut parent = alias_path;
|
||||
while let Some((prefix, _)) = parent.rsplit_once('/') {
|
||||
if shapes.get(prefix) == Some(&EntryShape::File) {
|
||||
eyre::bail!("download path descends through a file: {display_path}");
|
||||
}
|
||||
parent = prefix;
|
||||
}
|
||||
|
||||
if shape == EntryShape::File {
|
||||
let descendant_prefix = format!("{alias_path}/");
|
||||
if shapes
|
||||
.range(descendant_prefix.clone()..)
|
||||
.next()
|
||||
.is_some_and(|(candidate, _)| candidate.starts_with(&descendant_prefix))
|
||||
{
|
||||
eyre::bail!("download file conflicts with a described child: {display_path}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_existing_destination(
|
||||
game_root: &Path,
|
||||
components: &[&str],
|
||||
@@ -650,486 +485,151 @@ const fn is_windows_reparse_point(_metadata: &Metadata) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use lanspread_db::content_manifest::{
|
||||
Blake3Digest,
|
||||
CatalogContentManifestBody,
|
||||
CatalogFileEntry,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::TempDir;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum TreeEntry {
|
||||
Directory,
|
||||
File(Vec<u8>),
|
||||
Symlink(PathBuf),
|
||||
Other,
|
||||
}
|
||||
|
||||
fn catalog() -> GameCatalog {
|
||||
GameCatalog::from_ids(["game".to_owned()])
|
||||
}
|
||||
|
||||
fn file(path: &str, size: u64) -> GameFileDescription {
|
||||
GameFileDescription {
|
||||
game_id: "game".to_owned(),
|
||||
relative_path: path.to_owned(),
|
||||
is_dir: false,
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
fn directory(path: &str) -> GameFileDescription {
|
||||
GameFileDescription {
|
||||
game_id: "game".to_owned(),
|
||||
relative_path: path.to_owned(),
|
||||
is_dir: true,
|
||||
size: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_descriptions() -> Vec<GameFileDescription> {
|
||||
vec![
|
||||
directory("game"),
|
||||
file("game/archive.eti", 10),
|
||||
file("game/version.ini", 8),
|
||||
]
|
||||
}
|
||||
|
||||
fn validate(
|
||||
temp: &TempDir,
|
||||
descriptions: Vec<GameFileDescription>,
|
||||
) -> eyre::Result<ValidatedDownloadManifest> {
|
||||
ValidatedDownloadManifest::from_protocol_v7(temp.path(), "game", descriptions, &catalog())
|
||||
}
|
||||
|
||||
fn snapshot_tree(root: &Path) -> BTreeMap<PathBuf, TreeEntry> {
|
||||
walkdir::WalkDir::new(root)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.map(|entry| entry.expect("test tree should be readable"))
|
||||
.filter(|entry| entry.path() != root)
|
||||
.map(|entry| {
|
||||
let relative = entry
|
||||
.path()
|
||||
.strip_prefix(root)
|
||||
.expect("entry should be below root")
|
||||
.to_path_buf();
|
||||
let file_type = entry.file_type();
|
||||
let value = if file_type.is_dir() {
|
||||
TreeEntry::Directory
|
||||
} else if file_type.is_file() {
|
||||
TreeEntry::File(
|
||||
std::fs::read(entry.path()).expect("test file should be readable"),
|
||||
)
|
||||
} else if file_type.is_symlink() {
|
||||
TreeEntry::Symlink(
|
||||
std::fs::read_link(entry.path()).expect("test link should be readable"),
|
||||
)
|
||||
} else {
|
||||
TreeEntry::Other
|
||||
};
|
||||
(relative, value)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, bytes: &[u8]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).expect("parent should be created");
|
||||
}
|
||||
std::fs::write(path, bytes).expect("test file should be written");
|
||||
}
|
||||
|
||||
fn assert_rejected_without_mutation(descriptions: Vec<GameFileDescription>) {
|
||||
let temp = TempDir::new("lanspread-manifest-unchanged");
|
||||
write_file(&temp.game_root().join("archive.eti"), b"original");
|
||||
write_file(&temp.game_root().join("version.ini"), b"20250101");
|
||||
write_file(&temp.game_root().join("local/save.dat"), b"save");
|
||||
write_file(&temp.path().join("sibling/local/save.dat"), b"sibling");
|
||||
let before = snapshot_tree(temp.path());
|
||||
|
||||
assert!(validate(&temp, descriptions).is_err());
|
||||
assert_eq!(snapshot_tree(temp.path()), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_v7_adapter_strips_exact_game_prefix() {
|
||||
let temp = TempDir::new("lanspread-manifest-valid");
|
||||
let manifest = validate(&temp, valid_descriptions()).expect("manifest should validate");
|
||||
|
||||
let paths = manifest
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|entry| entry.destination().canonical())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(paths, ["archive.eti", "version.ini"]);
|
||||
let version_ini = manifest
|
||||
.entries()
|
||||
.iter()
|
||||
.find(|entry| entry.is_version_ini())
|
||||
.expect("version.ini should exist");
|
||||
assert_eq!(version_ini.protocol_path(), "game/version.ini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_nfc_catalog_game_id_and_path_components() {
|
||||
let temp = TempDir::new("lanspread-manifest-nfc-valid");
|
||||
let game_id = "g\u{e1}me";
|
||||
let catalog = GameCatalog::from_ids([game_id.to_owned()]);
|
||||
let descriptions = vec![
|
||||
GameFileDescription {
|
||||
game_id: game_id.to_owned(),
|
||||
relative_path: game_id.to_owned(),
|
||||
is_dir: true,
|
||||
size: 0,
|
||||
},
|
||||
GameFileDescription {
|
||||
game_id: game_id.to_owned(),
|
||||
relative_path: format!("{game_id}/caf\u{e9}/archive.eti"),
|
||||
is_dir: false,
|
||||
size: 10,
|
||||
},
|
||||
GameFileDescription {
|
||||
game_id: game_id.to_owned(),
|
||||
relative_path: format!("{game_id}/version.ini"),
|
||||
is_dir: false,
|
||||
size: 8,
|
||||
},
|
||||
];
|
||||
|
||||
let manifest = ValidatedDownloadManifest::from_protocol_v7(
|
||||
temp.path(),
|
||||
game_id,
|
||||
descriptions,
|
||||
&catalog,
|
||||
fn catalog_manifest() -> Arc<CatalogContentManifest> {
|
||||
let archive_digest = Blake3Digest::hash(b"abc");
|
||||
let version_digest = Blake3Digest::hash(b"1");
|
||||
Arc::new(
|
||||
CatalogContentManifest::seal(
|
||||
CatalogContentManifestBody::new(
|
||||
"game",
|
||||
"1",
|
||||
vec![
|
||||
CatalogFileEntry::directory("data")
|
||||
.expect("catalog directory should validate"),
|
||||
CatalogFileEntry::file(
|
||||
"data/archive.eti",
|
||||
3,
|
||||
archive_digest,
|
||||
vec![archive_digest],
|
||||
)
|
||||
.expect("catalog archive should validate"),
|
||||
CatalogFileEntry::file(
|
||||
"version.ini",
|
||||
1,
|
||||
version_digest,
|
||||
vec![version_digest],
|
||||
)
|
||||
.expect("catalog version should validate"),
|
||||
],
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("catalog body should validate"),
|
||||
)
|
||||
.expect("catalog should seal"),
|
||||
)
|
||||
.expect("NFC-normalized names should validate");
|
||||
}
|
||||
|
||||
assert_eq!(manifest.game_id(), game_id);
|
||||
#[test]
|
||||
fn catalog_authority_and_typed_root_relative_paths_are_retained() {
|
||||
let catalog = catalog_manifest();
|
||||
let expected_content_id = catalog.content_id();
|
||||
let temp = TempDir::new("lanspread-catalog-manifest");
|
||||
|
||||
let manifest = ValidatedDownloadManifest::from_catalog(temp.path(), Arc::clone(&catalog))
|
||||
.expect("catalog manifest should become download authority");
|
||||
|
||||
assert!(Arc::ptr_eq(manifest.catalog_manifest(), &catalog));
|
||||
assert_eq!(manifest.content_id(), expected_content_id);
|
||||
assert_eq!(
|
||||
manifest
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|entry| entry.destination().catalog_path().as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["data", "data/archive.eti", "version.ini"]
|
||||
);
|
||||
assert!(
|
||||
manifest
|
||||
.entries()
|
||||
.iter()
|
||||
.any(|entry| entry.destination().canonical() == "caf\u{e9}/archive.eti")
|
||||
.all(|entry| !entry.destination().canonical().starts_with("game/")),
|
||||
"wire paths must remain canonical game-root-relative catalog paths"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_nfc_catalog_game_id_without_mutation() {
|
||||
let temp = TempDir::new("lanspread-manifest-nfc-game-id");
|
||||
write_file(&temp.path().join("existing/file.bin"), b"unchanged");
|
||||
let before = snapshot_tree(temp.path());
|
||||
let game_id = "ga\u{301}me";
|
||||
let catalog = GameCatalog::from_ids([game_id.to_owned()]);
|
||||
let descriptions = vec![GameFileDescription {
|
||||
game_id: game_id.to_owned(),
|
||||
relative_path: format!("{game_id}/version.ini"),
|
||||
is_dir: false,
|
||||
size: 8,
|
||||
}];
|
||||
|
||||
let error = ValidatedDownloadManifest::from_protocol_v7(
|
||||
temp.path(),
|
||||
game_id,
|
||||
descriptions,
|
||||
&catalog,
|
||||
)
|
||||
.expect_err("non-NFC catalog game ID should fail");
|
||||
|
||||
assert!(error.to_string().contains("Unicode NFC normalization"));
|
||||
assert_eq!(snapshot_tree(temp.path()), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_nfc_download_component_without_mutation() {
|
||||
assert_rejected_without_mutation(vec![
|
||||
file("game/cafe\u{301}/archive.eti", 10),
|
||||
file("game/version.ini", 8),
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_catalog_game() {
|
||||
let temp = TempDir::new("lanspread-manifest-unknown");
|
||||
let error = ValidatedDownloadManifest::from_protocol_v7(
|
||||
temp.path(),
|
||||
"unknown",
|
||||
Vec::new(),
|
||||
&catalog(),
|
||||
)
|
||||
.expect_err("unknown game should fail");
|
||||
assert!(error.to_string().contains("unknown catalog game"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_different_and_doubled_game_prefixes() {
|
||||
let temp = TempDir::new("lanspread-manifest-prefix");
|
||||
for path in ["version.ini", "other/version.ini", "game/game/version.ini"] {
|
||||
let descriptions = vec![file("game/archive.eti", 10), file(path, 8)];
|
||||
assert!(validate(&temp, descriptions).is_err(), "accepted {path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_cross_game_description_identity() {
|
||||
let temp = TempDir::new("lanspread-manifest-cross-game");
|
||||
let mut descriptions = valid_descriptions();
|
||||
descriptions[1].game_id = "other".to_owned();
|
||||
assert!(validate(&temp, descriptions).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_noncanonical_and_nonportable_paths() {
|
||||
let temp = TempDir::new("lanspread-manifest-noncanonical");
|
||||
fn ownership_paths_reuse_typed_catalog_validation_and_reject_reserved_roots() {
|
||||
assert_eq!(
|
||||
ValidatedDownloadPath::from_ownership("data/archive.eti")
|
||||
.expect("catalog path should validate")
|
||||
.catalog_path()
|
||||
.as_str(),
|
||||
"data/archive.eti"
|
||||
);
|
||||
for path in [
|
||||
"game/a\\b.eti",
|
||||
"game//archive.eti",
|
||||
"game/./archive.eti",
|
||||
"game/../archive.eti",
|
||||
"game/archive.eti/",
|
||||
"game/C:/archive.eti",
|
||||
"game/archive?.eti",
|
||||
"game/archive.eti\0suffix",
|
||||
"version.ini",
|
||||
"local/save.dat",
|
||||
".sync/state",
|
||||
"../escape",
|
||||
"/absolute",
|
||||
"back\\slash",
|
||||
"NUL.txt",
|
||||
] {
|
||||
let descriptions = vec![file(path, 10), file("game/version.ini", 8)];
|
||||
assert!(validate(&temp, descriptions).is_err(), "accepted {path:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_portability_aliases_and_path_length_overflows() {
|
||||
let temp = TempDir::new("lanspread-manifest-portability");
|
||||
for path in [
|
||||
"game/.ſync/state",
|
||||
"game/COM0",
|
||||
"game/LPT0.txt",
|
||||
"game/COM¹.txt",
|
||||
"game/LPT³.log",
|
||||
"game/CON .txt",
|
||||
"game/CON\u{00a0}",
|
||||
"game/LOCAL~1/save.dat",
|
||||
] {
|
||||
let descriptions = vec![file(path, 1), file("game/version.ini", 8)];
|
||||
assert!(validate(&temp, descriptions).is_err(), "accepted {path}");
|
||||
}
|
||||
|
||||
let long_component = format!("game/{}", "a".repeat(MAX_DOWNLOAD_COMPONENT_BYTES + 1));
|
||||
assert!(
|
||||
validate(
|
||||
&temp,
|
||||
vec![file(&long_component, 1), file("game/version.ini", 8)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let long_relative = std::iter::repeat_n("a".repeat(200), 5)
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
let long_path = format!("game/{long_relative}");
|
||||
assert!(
|
||||
validate(
|
||||
&temp,
|
||||
vec![file(&long_path, 1), file("game/version.ini", 8)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_install_and_recovery_owned_roots() {
|
||||
let temp = TempDir::new("lanspread-manifest-reserved");
|
||||
for component in [
|
||||
"local",
|
||||
"LOCAL",
|
||||
".local.installing",
|
||||
".local.backup",
|
||||
".sync",
|
||||
".lanspread",
|
||||
".lanspread.json",
|
||||
".lanspread.json.tmp",
|
||||
".lanspread_owned",
|
||||
".softlan_first_start_done",
|
||||
".softlan_game_installed",
|
||||
".version.ini.tmp",
|
||||
".version.ini.discarded",
|
||||
"install_intent.json",
|
||||
"install_intent.json.tmp",
|
||||
] {
|
||||
let path = format!("game/{component}/payload.bin");
|
||||
let descriptions = vec![file(&path, 10), file("game/version.ini", 8)];
|
||||
assert!(
|
||||
validate(&temp, descriptions).is_err(),
|
||||
"accepted protected path {path}"
|
||||
ValidatedDownloadPath::from_ownership(path).is_err(),
|
||||
"accepted invalid ownership path {path:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicates_platform_aliases_and_shape_conflicts() {
|
||||
let temp = TempDir::new("lanspread-manifest-alias");
|
||||
let cases = [
|
||||
vec![file("game/A.eti", 1), file("game/a.eti", 1)],
|
||||
vec![file("game/archive.eti", 1), file("game/archive.eti", 1)],
|
||||
vec![file("game/con.txt", 1)],
|
||||
vec![file("game/archive.eti.", 1)],
|
||||
vec![file("game/archive.eti ", 1)],
|
||||
vec![file("game/dir", 1), file("game/dir/child", 1)],
|
||||
vec![file("game/dir/child", 1), file("game/dir", 1)],
|
||||
vec![directory("game/dir"), file("game/dir", 1)],
|
||||
vec![directory("game"), directory("game")],
|
||||
];
|
||||
for mut descriptions in cases {
|
||||
descriptions.push(file("game/version.ini", 8));
|
||||
assert!(validate(&temp, descriptions).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_peer_validation_rejects_duplicates_before_consensus() {
|
||||
let descriptions = vec![
|
||||
file("game/version.ini", 8),
|
||||
file("game/archive.eti", 1),
|
||||
file("game/archive.eti", 1),
|
||||
];
|
||||
assert!(validate_protocol_v7_descriptions("game", descriptions).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_exactly_one_regular_root_version_ini() {
|
||||
let temp = TempDir::new("lanspread-manifest-version");
|
||||
assert!(validate(&temp, vec![file("game/archive.eti", 1)]).is_err());
|
||||
assert!(
|
||||
validate(
|
||||
&temp,
|
||||
vec![file("game/version.ini", 8), file("game/version.ini", 8)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(validate(&temp, vec![directory("game/version.ini")]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_nonzero_directory_and_size_limits() {
|
||||
let temp = TempDir::new("lanspread-manifest-limits");
|
||||
let mut bad_dir = directory("game/data");
|
||||
bad_dir.size = 1;
|
||||
assert!(validate(&temp, vec![bad_dir, file("game/version.ini", 8)]).is_err());
|
||||
assert!(
|
||||
validate(
|
||||
&temp,
|
||||
vec![
|
||||
file("game/archive.eti", MAX_DOWNLOAD_FILE_BYTES + 1),
|
||||
file("game/version.ini", 8),
|
||||
]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate(
|
||||
&temp,
|
||||
vec![
|
||||
file("game/version.ini", MAX_VERSION_INI_BYTES + 1),
|
||||
file("game/archive.eti", 1),
|
||||
]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let descriptions = vec![file("game/version.ini", 8); MAX_DOWNLOAD_MANIFEST_ENTRIES + 1];
|
||||
assert!(validate(&temp, descriptions).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_late_descriptor_leaves_filesystem_unchanged() {
|
||||
let temp = TempDir::new("lanspread-manifest-zero-mutation");
|
||||
fn catalog_validation_rejects_existing_destination_shape_conflicts_without_mutation() {
|
||||
let temp = TempDir::new("lanspread-catalog-shape-conflict");
|
||||
let game_root = temp.game_root();
|
||||
std::fs::create_dir_all(&game_root).expect("game root should be created");
|
||||
std::fs::write(game_root.join("archive.eti"), b"original")
|
||||
.expect("existing archive should be written");
|
||||
std::fs::write(game_root.join("version.ini"), b"20250101")
|
||||
.expect("existing version should be written");
|
||||
|
||||
let descriptions = vec![
|
||||
file("game/archive.eti", 1),
|
||||
file("game/version.ini", 8),
|
||||
file("game/local/save.dat", 1),
|
||||
];
|
||||
assert!(validate(&temp, descriptions).is_err());
|
||||
std::fs::write(game_root.join("data"), b"existing file")
|
||||
.expect("conflicting file should be created");
|
||||
let before = std::fs::read(game_root.join("data")).expect("file should be readable");
|
||||
|
||||
assert!(ValidatedDownloadManifest::from_catalog(temp.path(), catalog_manifest()).is_err());
|
||||
assert_eq!(
|
||||
std::fs::read(game_root.join("archive.eti")).expect("archive should remain"),
|
||||
b"original"
|
||||
std::fs::read(game_root.join("data")).expect("file should remain readable"),
|
||||
before
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(game_root.join("version.ini")).expect("version should remain"),
|
||||
b"20250101"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_dir(&game_root)
|
||||
.expect("game root should remain readable")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejection_categories_leave_the_complete_tree_unchanged() {
|
||||
let cases = [
|
||||
vec![file("game/version.ini", 8), file("other/local/save.dat", 1)],
|
||||
vec![file("game/version.ini", 8), file("game/local/save.dat", 1)],
|
||||
vec![file("game/version.ini", 8), file("game/.sync/state", 1)],
|
||||
vec![file("game/version.ini", 8), file("game/COM0", 1)],
|
||||
vec![file("game/version.ini", 8), file("game/CON\u{00a0}", 1)],
|
||||
vec![file("game/version.ini", 8), file("game/../escape", 1)],
|
||||
vec![
|
||||
file("game/version.ini", 8),
|
||||
file("game/A.eti", 1),
|
||||
file("game/a.eti", 1),
|
||||
],
|
||||
vec![
|
||||
file("game/version.ini", 8),
|
||||
file("game/dir", 1),
|
||||
file("game/dir/child", 1),
|
||||
],
|
||||
vec![file("game/archive.eti", 1)],
|
||||
vec![directory("game/version.ini")],
|
||||
vec![
|
||||
file("game/version.ini", 8),
|
||||
file("game/archive.eti", MAX_DOWNLOAD_FILE_BYTES + 1),
|
||||
],
|
||||
vec![
|
||||
directory("game"),
|
||||
directory("game"),
|
||||
file("game/version.ini", 8),
|
||||
],
|
||||
];
|
||||
for descriptions in cases {
|
||||
assert_rejected_without_mutation(descriptions);
|
||||
}
|
||||
|
||||
assert_rejected_without_mutation(vec![
|
||||
file("game/version.ini", 8);
|
||||
MAX_DOWNLOAD_MANIFEST_ENTRIES + 1
|
||||
]);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symlink_game_roots_and_destination_components() {
|
||||
fn catalog_validation_rejects_symlink_game_roots_and_destination_components() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root_link = TempDir::new("lanspread-manifest-root-link");
|
||||
let outside = TempDir::new("lanspread-manifest-outside");
|
||||
let root_link = TempDir::new("lanspread-catalog-root-link");
|
||||
let outside = TempDir::new("lanspread-catalog-outside");
|
||||
std::fs::write(outside.path().join("canary"), b"outside")
|
||||
.expect("outside canary should be created");
|
||||
symlink(outside.path(), root_link.path().join("game"))
|
||||
.expect("game root symlink should be created");
|
||||
assert!(validate(&root_link, valid_descriptions()).is_err());
|
||||
|
||||
let child_link = TempDir::new("lanspread-manifest-child-link");
|
||||
assert!(
|
||||
ValidatedDownloadManifest::from_catalog(root_link.path(), catalog_manifest()).is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(outside.path().join("canary")).expect("canary should remain"),
|
||||
b"outside"
|
||||
);
|
||||
|
||||
let child_link = TempDir::new("lanspread-catalog-child-link");
|
||||
std::fs::create_dir_all(child_link.game_root()).expect("game root should be created");
|
||||
symlink(outside.path(), child_link.game_root().join("payload"))
|
||||
symlink(outside.path(), child_link.game_root().join("data"))
|
||||
.expect("child symlink should be created");
|
||||
let descriptions = vec![
|
||||
file("game/payload/file.bin", 1),
|
||||
file("game/version.ini", 8),
|
||||
];
|
||||
assert!(validate(&child_link, descriptions).is_err());
|
||||
|
||||
assert!(
|
||||
ValidatedDownloadManifest::from_catalog(child_link.path(), catalog_manifest()).is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(outside.path().join("canary")).expect("canary should remain"),
|
||||
b"outside"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,24 @@ mod progress;
|
||||
mod retry;
|
||||
mod storage;
|
||||
mod task_drain;
|
||||
mod transfer_error;
|
||||
mod transport;
|
||||
mod version_ini;
|
||||
|
||||
pub(crate) use manifest::{ValidatedDownloadManifest, validate_protocol_v7_descriptions};
|
||||
pub(crate) use orchestrator::download_game_files;
|
||||
pub(crate) use confined_fs::open_catalog_file_for_read;
|
||||
pub(crate) use manifest::ValidatedDownloadManifest;
|
||||
pub(crate) use orchestrator::{DownloadCompletion, DownloadGameRequest, download_game_files};
|
||||
pub(crate) use ownership::{
|
||||
DownloadOwnershipReadiness,
|
||||
download_ownership_matches_content,
|
||||
download_ownership_readiness,
|
||||
recover_incomplete_download,
|
||||
remove_downloaded_payload,
|
||||
scan_download_ownership_recovery_ids,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use ownership::seed_download_ownership_for_test;
|
||||
pub(crate) use ownership::{recover_incomplete_download, remove_downloaded_payload};
|
||||
pub(crate) use ownership::{
|
||||
seed_download_ownership_for_test,
|
||||
seed_pending_download_ownership_for_test,
|
||||
};
|
||||
pub(crate) use transfer_error::{DownloadTransferError, DownloadTransferErrorKind};
|
||||
@@ -1,19 +1,35 @@
|
||||
use std::{collections::HashMap, net::SocketAddr, path::Path, sync::Arc};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt,
|
||||
net::SocketAddr,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use futures::stream::FuturesUnordered;
|
||||
use lanspread_db::content_manifest::ContentId;
|
||||
use lanspread_proto::PeerEndpoint;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::{
|
||||
DownloadTransferError,
|
||||
DownloadTransferErrorKind,
|
||||
confined_fs::ConfinedGameRoot,
|
||||
manifest::{ValidatedDownloadEntry, ValidatedDownloadManifest},
|
||||
ownership::{DownloadOwnershipTransaction, OwnershipJournalPublication},
|
||||
planning::{ChunkDownloadResult, DownloadChunk, build_peer_plans},
|
||||
planning::{
|
||||
ChunkDownloadResult,
|
||||
DownloadChunk,
|
||||
PeerDownloadPlan,
|
||||
build_peer_plans,
|
||||
reconcile_chunk_results,
|
||||
},
|
||||
progress::{DownloadProgressTracker, sample_download_progress},
|
||||
retry::{RetryContext, retry_failed_chunks},
|
||||
retry::{RetryChunk, RetryContext, quarantine_if_integrity_failure, retry_failed_chunks},
|
||||
storage::{prepare_game_storage, sync_game_storage},
|
||||
task_drain::collect_or_drain_on_cancel,
|
||||
transport::download_from_peer,
|
||||
transport::{PeerDownloadRequest, download_from_peer},
|
||||
version_ini::{
|
||||
VersionIniBuffer,
|
||||
VersionIniCommit,
|
||||
@@ -22,48 +38,187 @@ use super::{
|
||||
restore_unjournaled_version_ini_transaction,
|
||||
},
|
||||
};
|
||||
use crate::{PeerEvent, config::MAX_RETRY_COUNT};
|
||||
use crate::{
|
||||
DownloadFailureReason,
|
||||
DownloadVerificationActivity,
|
||||
PeerEvent,
|
||||
content_quarantine::ContentQuarantine,
|
||||
peer_db::PeerId,
|
||||
quic_runtime::QuicConnector,
|
||||
transfer_status::{DownloadAttemptReporter, DownloadAttemptStatus},
|
||||
};
|
||||
|
||||
/// Terminal state of a complete payload transfer.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum DownloadCompletion {
|
||||
/// Payload, sentinel, and ownership state are durably settled.
|
||||
Durable,
|
||||
/// The committed payload is visible, but recovery must settle its metadata
|
||||
/// before it can be advertised, served, or installed.
|
||||
RecoveryRequired(eyre::Report),
|
||||
}
|
||||
|
||||
/// Typed owner-facing failure from one complete ordinary download operation.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct DownloadOperationError {
|
||||
reason: Option<DownloadFailureReason>,
|
||||
error: eyre::Report,
|
||||
}
|
||||
|
||||
impl DownloadOperationError {
|
||||
pub(super) fn cancelled(error: impl Into<eyre::Report>) -> Self {
|
||||
Self {
|
||||
reason: None,
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn sources_exhausted(error: impl Into<eyre::Report>) -> Self {
|
||||
Self {
|
||||
reason: Some(DownloadFailureReason::VerifiedCatalogSourcesExhausted),
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn operation_failed(error: impl Into<eyre::Report>) -> Self {
|
||||
Self {
|
||||
reason: Some(DownloadFailureReason::OperationFailed),
|
||||
error: error.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn reason(&self) -> Option<DownloadFailureReason> {
|
||||
self.reason
|
||||
}
|
||||
|
||||
pub(crate) fn into_report(self) -> eyre::Report {
|
||||
self.error
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregates independent chunk failures without letting a later retryable
|
||||
/// source failure downgrade an already-observed local operation failure.
|
||||
#[derive(Default)]
|
||||
struct TransferFailureAggregate {
|
||||
operation_failed: Option<DownloadTransferError>,
|
||||
cancelled: Option<DownloadTransferError>,
|
||||
sources_exhausted: Option<DownloadTransferError>,
|
||||
}
|
||||
|
||||
impl TransferFailureAggregate {
|
||||
fn record(&mut self, error: DownloadTransferError) {
|
||||
match error.kind() {
|
||||
DownloadTransferErrorKind::LocalIo => {
|
||||
self.operation_failed.get_or_insert(error);
|
||||
}
|
||||
DownloadTransferErrorKind::Cancelled => {
|
||||
self.cancelled.get_or_insert(error);
|
||||
}
|
||||
DownloadTransferErrorKind::Integrity | DownloadTransferErrorKind::Transport => {
|
||||
self.sources_exhausted.get_or_insert(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn into_operation_error(self) -> Option<DownloadOperationError> {
|
||||
if let Some(error) = self.operation_failed {
|
||||
return Some(DownloadOperationError::operation_failed(error));
|
||||
}
|
||||
if let Some(error) = self.cancelled {
|
||||
return Some(DownloadOperationError::cancelled(error));
|
||||
}
|
||||
self.sources_exhausted
|
||||
.map(DownloadOperationError::sources_exhausted)
|
||||
}
|
||||
|
||||
fn cancellation_error(&mut self, game_id: &str) -> DownloadOperationError {
|
||||
self.operation_failed.take().map_or_else(
|
||||
|| cancelled_download(game_id),
|
||||
DownloadOperationError::operation_failed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DownloadOperationError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.error.fmt(formatter)
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete authority and runtime input for one ordinary catalog download.
|
||||
pub(crate) struct DownloadGameRequest<'a> {
|
||||
pub(crate) attempt: &'a DownloadAttemptStatus,
|
||||
pub(crate) manifest: ValidatedDownloadManifest,
|
||||
pub(crate) state_dir: &'a Path,
|
||||
pub(crate) sources: &'a [PeerEndpoint],
|
||||
pub(crate) content_id: ContentId,
|
||||
pub(crate) quarantine: &'a ContentQuarantine,
|
||||
pub(crate) tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
pub(crate) cancel_token: CancellationToken,
|
||||
pub(crate) quic: QuicConnector,
|
||||
}
|
||||
|
||||
/// Downloads all game files from available peers.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub(crate) async fn download_game_files(
|
||||
manifest: ValidatedDownloadManifest,
|
||||
state_dir: &Path,
|
||||
peers: Vec<SocketAddr>,
|
||||
file_peer_map: HashMap<String, Vec<SocketAddr>>,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
cancel_token: CancellationToken,
|
||||
) -> eyre::Result<()> {
|
||||
request: DownloadGameRequest<'_>,
|
||||
) -> Result<DownloadCompletion, DownloadOperationError> {
|
||||
let DownloadGameRequest {
|
||||
attempt,
|
||||
manifest,
|
||||
state_dir,
|
||||
sources,
|
||||
content_id,
|
||||
quarantine,
|
||||
tx_notify_ui,
|
||||
cancel_token,
|
||||
quic,
|
||||
} = request;
|
||||
let game_id = manifest.game_id().to_owned();
|
||||
if peers.is_empty() {
|
||||
eyre::bail!("no peers available for game {game_id}");
|
||||
let manifest_content_id = manifest.catalog_manifest().content_id();
|
||||
if manifest_content_id != content_id {
|
||||
return Err(DownloadOperationError::operation_failed(eyre::eyre!(
|
||||
"download content ID does not match catalog authority for game {game_id}: requested {content_id}, catalog {manifest_content_id}"
|
||||
)));
|
||||
}
|
||||
let sources = eligible_content_sources(sources, content_id, quarantine)
|
||||
.map_err(DownloadOperationError::operation_failed)?;
|
||||
if sources.is_empty() {
|
||||
return Err(DownloadOperationError::sources_exhausted(eyre::eyre!(
|
||||
"no peers available for game {game_id}"
|
||||
)));
|
||||
}
|
||||
|
||||
if cancel_token.is_cancelled() {
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
return Err(cancelled_download(&game_id));
|
||||
}
|
||||
|
||||
let version_entry = manifest.version_entry();
|
||||
let version_buffer =
|
||||
match VersionIniBuffer::new(version_entry.protocol_path(), version_entry.size()) {
|
||||
Ok(buffer) => Arc::new(buffer),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let confined_root = ConfinedGameRoot::open_or_create(manifest.games_folder(), &game_id).await?;
|
||||
let ownership =
|
||||
DownloadOwnershipTransaction::prepare(state_dir, &manifest, &confined_root).await?;
|
||||
let version_buffer = match VersionIniBuffer::new(
|
||||
version_entry.destination().canonical(),
|
||||
version_entry.size(),
|
||||
) {
|
||||
Ok(buffer) => Arc::new(buffer),
|
||||
Err(err) => return Err(DownloadOperationError::operation_failed(err)),
|
||||
};
|
||||
let confined_root = ConfinedGameRoot::open_or_create(manifest.games_folder(), &game_id)
|
||||
.map_err(DownloadOperationError::operation_failed)?;
|
||||
let ownership = DownloadOwnershipTransaction::prepare(state_dir, &manifest, &confined_root)
|
||||
.await
|
||||
.map_err(DownloadOperationError::operation_failed)?;
|
||||
|
||||
if let Err(error) = begin_version_ini_transaction(&confined_root).await {
|
||||
if let Err(restore_error) = restore_before_ownership_journal(&confined_root).await {
|
||||
return Err(error.wrap_err(format!(
|
||||
"sentinel parking failed and rollback also failed: {restore_error}"
|
||||
if let Err(error) = begin_version_ini_transaction(&confined_root) {
|
||||
if let Err(restore_error) = restore_before_ownership_journal(&confined_root) {
|
||||
return Err(DownloadOperationError::operation_failed(error.wrap_err(
|
||||
format!("sentinel parking failed and rollback also failed: {restore_error}"),
|
||||
)));
|
||||
}
|
||||
return Err(error);
|
||||
return Err(DownloadOperationError::operation_failed(error));
|
||||
}
|
||||
if cancel_token.is_cancelled() {
|
||||
restore_before_ownership_journal(&confined_root).await?;
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
restore_before_ownership_journal(&confined_root)
|
||||
.map_err(DownloadOperationError::operation_failed)?;
|
||||
return Err(cancelled_download(&game_id));
|
||||
}
|
||||
match ownership.journal_pending().await {
|
||||
Ok(OwnershipJournalPublication::Durable) => {}
|
||||
@@ -71,83 +226,92 @@ pub(crate) async fn download_game_files(
|
||||
// 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!(
|
||||
return Err(DownloadOperationError::operation_failed(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}"
|
||||
if let Err(restore_error) = restore_before_ownership_journal(&confined_root) {
|
||||
return Err(DownloadOperationError::operation_failed(error.wrap_err(
|
||||
format!(
|
||||
"ownership journal failed and sentinel restore also failed: {restore_error}"
|
||||
),
|
||||
)));
|
||||
}
|
||||
return Err(error);
|
||||
return Err(DownloadOperationError::operation_failed(error));
|
||||
}
|
||||
}
|
||||
if let Err(err) = prepare_game_storage(&manifest, &confined_root).await {
|
||||
abort_download_best_effort(&ownership, &game_id).await;
|
||||
if cancel_token.is_cancelled() {
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
return Err(err);
|
||||
if let Err(err) = prepare_game_storage(&manifest, &confined_root) {
|
||||
let failure = DownloadOperationError::operation_failed(err);
|
||||
return Err(abort_download(&ownership, &game_id, failure).await);
|
||||
}
|
||||
if cancel_token.is_cancelled() {
|
||||
abort_download_best_effort(&ownership, &game_id).await;
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
|
||||
if let Err(error) = tx_notify_ui.send(PeerEvent::DownloadGameFilesBegin {
|
||||
id: game_id.clone(),
|
||||
}) {
|
||||
abort_download_best_effort(&ownership, &game_id).await;
|
||||
return Err(error.into());
|
||||
let failure = cancelled_download(&game_id);
|
||||
return Err(abort_download(&ownership, &game_id, failure).await);
|
||||
}
|
||||
|
||||
let progress_tracker = DownloadProgressTracker::new(total_download_bytes(manifest.entries()));
|
||||
let attempt_reporter = attempt.reporter();
|
||||
let transfer_ctx = TransferContext {
|
||||
game_id: &game_id,
|
||||
game_root: &confined_root,
|
||||
peers: &peers,
|
||||
file_peer_map: &file_peer_map,
|
||||
sources: &sources,
|
||||
content_id,
|
||||
quarantine,
|
||||
tx_notify_ui: &tx_notify_ui,
|
||||
cancel_token: &cancel_token,
|
||||
quic: &quic,
|
||||
version_buffer: version_buffer.clone(),
|
||||
progress_tracker: progress_tracker.clone(),
|
||||
attempt: attempt_reporter.clone(),
|
||||
};
|
||||
let plans = match build_initial_transfer_plans(&transfer_ctx, &manifest) {
|
||||
Ok(plans) => plans,
|
||||
Err(error) => {
|
||||
attempt.close_source_admission();
|
||||
return Err(abort_download(&ownership, &game_id, error).await);
|
||||
}
|
||||
};
|
||||
attempt.emit_begin();
|
||||
attempt.set_activity(DownloadVerificationActivity::VerifyingDownloadedChunks);
|
||||
let transfer_result = sample_download_progress(
|
||||
&game_id,
|
||||
attempt_reporter,
|
||||
progress_tracker,
|
||||
tx_notify_ui.clone(),
|
||||
download_transfer_chunks(&transfer_ctx, manifest.entries()),
|
||||
download_transfer_chunks(&transfer_ctx, plans),
|
||||
)
|
||||
.await;
|
||||
attempt.close_source_admission();
|
||||
attempt.clear_activity();
|
||||
|
||||
if let Err(err) = transfer_result {
|
||||
abort_download_best_effort(&ownership, &game_id).await;
|
||||
return Err(err);
|
||||
return Err(abort_download(&ownership, &game_id, err).await);
|
||||
}
|
||||
|
||||
if cancel_token.is_cancelled() {
|
||||
abort_download_best_effort(&ownership, &game_id).await;
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
let failure = cancelled_download(&game_id);
|
||||
return Err(abort_download(&ownership, &game_id, failure).await);
|
||||
}
|
||||
|
||||
if let Err(error) = sync_game_storage(&manifest, &confined_root).await {
|
||||
abort_download_best_effort(&ownership, &game_id).await;
|
||||
return Err(error.wrap_err("failed to make downloaded payload durable"));
|
||||
if let Err(error) = sync_game_storage(&manifest, &confined_root) {
|
||||
let failure = DownloadOperationError::operation_failed(
|
||||
error.wrap_err("failed to make downloaded payload durable"),
|
||||
);
|
||||
return Err(abort_download(&ownership, &game_id, failure).await);
|
||||
}
|
||||
if cancel_token.is_cancelled() {
|
||||
abort_download_best_effort(&ownership, &game_id).await;
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
let failure = cancelled_download(&game_id);
|
||||
return Err(abort_download(&ownership, &game_id, failure).await);
|
||||
}
|
||||
|
||||
if let Err(error) = ownership.remove_stale().await {
|
||||
abort_download_best_effort(&ownership, &game_id).await;
|
||||
return Err(error.wrap_err("failed to remove stale download-owned files"));
|
||||
if let Err(error) = ownership.remove_stale() {
|
||||
let failure = DownloadOperationError::operation_failed(
|
||||
error.wrap_err("failed to remove stale download-owned files"),
|
||||
);
|
||||
return Err(abort_download(&ownership, &game_id, failure).await);
|
||||
}
|
||||
if cancel_token.is_cancelled() {
|
||||
abort_download_best_effort(&ownership, &game_id).await;
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
let failure = cancelled_download(&game_id);
|
||||
return Err(abort_download(&ownership, &game_id, failure).await);
|
||||
}
|
||||
|
||||
match commit_version_ini_buffer(&confined_root, &version_buffer).await {
|
||||
@@ -155,202 +319,322 @@ pub(crate) async fn download_game_files(
|
||||
Ok(VersionIniCommit::NeedsRecovery(error)) => {
|
||||
// The visible sentinel makes rollback unsafe. Keep pending ownership
|
||||
// so startup recovery can decide from the durable filesystem state.
|
||||
return Err(eyre::eyre!(
|
||||
return Ok(DownloadCompletion::RecoveryRequired(eyre::eyre!(
|
||||
"version.ini was renamed but its durability could not be established: {error}"
|
||||
));
|
||||
)));
|
||||
}
|
||||
Err(error) => {
|
||||
abort_download_best_effort(&ownership, &game_id).await;
|
||||
return Err(error);
|
||||
let failure = DownloadOperationError::operation_failed(error);
|
||||
return Err(abort_download(&ownership, &game_id, failure).await);
|
||||
}
|
||||
}
|
||||
if let Err(error) = ownership.finalize().await {
|
||||
// The sentinel rename is the commit point. Pending ownership lets the
|
||||
// next recovery or download finish this idempotently.
|
||||
log::error!("Downloaded {game_id}, but ownership finalization must be recovered: {error}");
|
||||
match ownership.finalize().await {
|
||||
Ok(OwnershipJournalPublication::Durable) => {}
|
||||
Ok(OwnershipJournalPublication::NeedsRecovery(error)) => {
|
||||
return Ok(DownloadCompletion::RecoveryRequired(error.wrap_err(
|
||||
"downloaded payload is visible, but ownership durability must be recovered",
|
||||
)));
|
||||
}
|
||||
Err(error) => {
|
||||
return Ok(DownloadCompletion::RecoveryRequired(error.wrap_err(
|
||||
"downloaded payload is visible, but ownership finalization must be recovered",
|
||||
)));
|
||||
}
|
||||
}
|
||||
log::info!("all files downloaded for game: {game_id}");
|
||||
Ok(())
|
||||
Ok(DownloadCompletion::Durable)
|
||||
}
|
||||
|
||||
async fn restore_before_ownership_journal(game_root: &ConfinedGameRoot) -> eyre::Result<()> {
|
||||
restore_unjournaled_version_ini_transaction(game_root).await
|
||||
fn restore_before_ownership_journal(game_root: &ConfinedGameRoot) -> eyre::Result<()> {
|
||||
restore_unjournaled_version_ini_transaction(game_root)
|
||||
}
|
||||
|
||||
async fn abort_download_best_effort(ownership: &DownloadOwnershipTransaction, game_id: &str) {
|
||||
if let Err(err) = ownership.abort().await {
|
||||
log::warn!("Failed to abort download-owned payload for {game_id}: {err}");
|
||||
fn cancelled_download(game_id: &str) -> DownloadOperationError {
|
||||
DownloadOperationError::cancelled(eyre::eyre!("download cancelled for game {game_id}"))
|
||||
}
|
||||
|
||||
async fn abort_download(
|
||||
ownership: &DownloadOwnershipTransaction,
|
||||
game_id: &str,
|
||||
failure: DownloadOperationError,
|
||||
) -> DownloadOperationError {
|
||||
match ownership.abort().await {
|
||||
Ok(()) => failure,
|
||||
Err(abort_error) => DownloadOperationError::operation_failed(eyre::eyre!(
|
||||
"download failed for {game_id}: {failure}; ownership rollback also failed: {abort_error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
struct TransferContext<'a> {
|
||||
game_id: &'a str,
|
||||
game_root: &'a ConfinedGameRoot,
|
||||
peers: &'a [SocketAddr],
|
||||
file_peer_map: &'a HashMap<String, Vec<SocketAddr>>,
|
||||
sources: &'a [PeerEndpoint],
|
||||
content_id: ContentId,
|
||||
quarantine: &'a ContentQuarantine,
|
||||
tx_notify_ui: &'a UnboundedSender<PeerEvent>,
|
||||
cancel_token: &'a CancellationToken,
|
||||
quic: &'a QuicConnector,
|
||||
version_buffer: Arc<VersionIniBuffer>,
|
||||
progress_tracker: Arc<DownloadProgressTracker>,
|
||||
attempt: DownloadAttemptReporter,
|
||||
}
|
||||
|
||||
struct InitialAttempt {
|
||||
source: PeerEndpoint,
|
||||
planned_chunks: Vec<DownloadChunk>,
|
||||
result: Result<Vec<ChunkDownloadResult>, DownloadTransferError>,
|
||||
}
|
||||
|
||||
fn eligible_content_sources(
|
||||
sources: &[PeerEndpoint],
|
||||
content_id: ContentId,
|
||||
quarantine: &ContentQuarantine,
|
||||
) -> eyre::Result<Vec<PeerEndpoint>> {
|
||||
let mut seen_peer_ids = HashSet::<PeerId>::new();
|
||||
let mut peer_by_addr = HashMap::<SocketAddr, PeerId>::new();
|
||||
let mut eligible = Vec::with_capacity(sources.len());
|
||||
|
||||
for source in sources {
|
||||
if !seen_peer_ids.insert(source.peer_id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(previous_peer_id) = peer_by_addr.insert(source.addr, source.peer_id) {
|
||||
eyre::bail!(
|
||||
"content source address {} is ambiguously assigned to peers {previous_peer_id} and {}",
|
||||
source.addr,
|
||||
source.peer_id
|
||||
);
|
||||
}
|
||||
if !quarantine.is_quarantined(source, content_id) {
|
||||
eligible.push(*source);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(eligible)
|
||||
}
|
||||
|
||||
async fn download_transfer_chunks(
|
||||
ctx: &TransferContext<'_>,
|
||||
transfer_descs: &[ValidatedDownloadEntry],
|
||||
) -> eyre::Result<()> {
|
||||
let plans = build_peer_plans(ctx.peers, transfer_descs, ctx.file_peer_map);
|
||||
|
||||
plans: HashMap<PeerEndpoint, PeerDownloadPlan>,
|
||||
) -> Result<(), DownloadOperationError> {
|
||||
let tasks = FuturesUnordered::new();
|
||||
for (peer_addr, plan) in plans {
|
||||
for (endpoint, plan) in plans {
|
||||
let source = endpoint;
|
||||
let planned_chunks = plan.chunks.clone();
|
||||
let game_root = ctx.game_root.clone();
|
||||
let game_id = ctx.game_id.to_string();
|
||||
let cancel_token = ctx.cancel_token.clone();
|
||||
let version_buffer = ctx.version_buffer.clone();
|
||||
let progress_tracker = ctx.progress_tracker.clone();
|
||||
let quic = ctx.quic.clone();
|
||||
tasks.push(async move {
|
||||
download_from_peer(
|
||||
peer_addr,
|
||||
&game_id,
|
||||
let result = download_from_peer(PeerDownloadRequest {
|
||||
quic,
|
||||
endpoint,
|
||||
game_id,
|
||||
plan,
|
||||
game_root,
|
||||
&cancel_token,
|
||||
Some(version_buffer),
|
||||
cancel_token,
|
||||
version_buffer,
|
||||
progress_tracker,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
InitialAttempt {
|
||||
source,
|
||||
planned_chunks,
|
||||
result,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut failed_chunks: Vec<DownloadChunk> = Vec::new();
|
||||
let mut last_err: Option<eyre::Report> = None;
|
||||
let mut failed_chunks = Vec::new();
|
||||
let mut failures = TransferFailureAggregate::default();
|
||||
|
||||
for result in collect_or_drain_on_cancel(tasks, ctx.cancel_token, ctx.game_id).await? {
|
||||
let attempts = collect_or_drain_on_cancel(tasks, ctx.cancel_token, ctx.game_id)
|
||||
.await
|
||||
.map_err(DownloadOperationError::cancelled)?;
|
||||
for attempt in attempts {
|
||||
if ctx.cancel_token.is_cancelled() {
|
||||
eyre::bail!("download cancelled for game {}", ctx.game_id);
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(results) => {
|
||||
collect_chunk_results(
|
||||
ctx.game_id,
|
||||
ctx.tx_notify_ui,
|
||||
results,
|
||||
&mut failed_chunks,
|
||||
&mut last_err,
|
||||
);
|
||||
}
|
||||
Err(_) if ctx.cancel_token.is_cancelled() => {
|
||||
eyre::bail!("download cancelled for game {}", ctx.game_id);
|
||||
}
|
||||
Err(e) => last_err = Some(e),
|
||||
return Err(failures.cancellation_error(ctx.game_id));
|
||||
}
|
||||
collect_initial_attempt(ctx, attempt, &mut failed_chunks, &mut failures)
|
||||
.map_err(DownloadOperationError::operation_failed)?;
|
||||
}
|
||||
|
||||
if !failed_chunks.is_empty() && !ctx.peers.is_empty() {
|
||||
retry_chunks(ctx, failed_chunks, &mut last_err).await?;
|
||||
if !failed_chunks.is_empty() {
|
||||
retry_chunks(ctx, failed_chunks, &mut failures).await?;
|
||||
}
|
||||
|
||||
if ctx.cancel_token.is_cancelled() {
|
||||
eyre::bail!("download cancelled for game {}", ctx.game_id);
|
||||
return Err(failures.cancellation_error(ctx.game_id));
|
||||
}
|
||||
|
||||
if let Some(err) = last_err {
|
||||
return Err(err);
|
||||
if let Some(error) = failures.into_operation_error() {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_chunk_results(
|
||||
game_id: &str,
|
||||
tx_notify_ui: &UnboundedSender<PeerEvent>,
|
||||
results: Vec<ChunkDownloadResult>,
|
||||
failed_chunks: &mut Vec<DownloadChunk>,
|
||||
last_err: &mut Option<eyre::Report>,
|
||||
) {
|
||||
for chunk_result in results {
|
||||
match chunk_result.result {
|
||||
Ok(()) => {
|
||||
let _ = tx_notify_ui.send(PeerEvent::DownloadGameFileChunkFinished {
|
||||
id: game_id.to_string(),
|
||||
peer_addr: chunk_result.peer_addr,
|
||||
relative_path: chunk_result.chunk.request_path,
|
||||
offset: chunk_result.chunk.offset,
|
||||
length: chunk_result.chunk.length,
|
||||
});
|
||||
fn build_initial_transfer_plans(
|
||||
ctx: &TransferContext<'_>,
|
||||
manifest: &ValidatedDownloadManifest,
|
||||
) -> Result<HashMap<PeerEndpoint, PeerDownloadPlan>, DownloadOperationError> {
|
||||
let sources = eligible_content_sources(ctx.sources, ctx.content_id, ctx.quarantine)
|
||||
.map_err(DownloadOperationError::operation_failed)?;
|
||||
if sources.is_empty() {
|
||||
return Err(DownloadOperationError::sources_exhausted(eyre::eyre!(
|
||||
"no nonquarantined sources remain for game {}",
|
||||
ctx.game_id
|
||||
)));
|
||||
}
|
||||
|
||||
// Local catalog identity defines the exact content and canonical path carried
|
||||
// by every request. Planning only distributes those immutable chunks over
|
||||
// authenticated, exact-content, quarantine-filtered endpoints.
|
||||
let plans =
|
||||
build_peer_plans(&sources, manifest).map_err(DownloadOperationError::operation_failed)?;
|
||||
if plans.is_empty() {
|
||||
return Err(DownloadOperationError::operation_failed(eyre::eyre!(
|
||||
"catalog download plan contains no chunks for game {}",
|
||||
ctx.game_id
|
||||
)));
|
||||
}
|
||||
Ok(plans)
|
||||
}
|
||||
|
||||
fn collect_initial_attempt(
|
||||
ctx: &TransferContext<'_>,
|
||||
attempt: InitialAttempt,
|
||||
failed_chunks: &mut Vec<RetryChunk>,
|
||||
failures: &mut TransferFailureAggregate,
|
||||
) -> eyre::Result<()> {
|
||||
let InitialAttempt {
|
||||
source,
|
||||
planned_chunks,
|
||||
result,
|
||||
} = attempt;
|
||||
match result {
|
||||
Ok(results) => {
|
||||
let expected_endpoint = source;
|
||||
for (planned_chunk, mut result) in reconcile_chunk_results(
|
||||
planned_chunks,
|
||||
results,
|
||||
expected_endpoint,
|
||||
|chunk| chunk,
|
||||
"initial download",
|
||||
)? {
|
||||
result.chunk = planned_chunk;
|
||||
collect_initial_chunk_result(ctx, &source, result, failed_chunks, failures);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to download chunk from {}: {e}",
|
||||
chunk_result.peer_addr
|
||||
}
|
||||
Err(error) => {
|
||||
for chunk in planned_chunks {
|
||||
collect_initial_chunk_result(
|
||||
ctx,
|
||||
&source,
|
||||
ChunkDownloadResult {
|
||||
chunk,
|
||||
result: Err(error.clone()),
|
||||
peer_endpoint: source,
|
||||
},
|
||||
failed_chunks,
|
||||
failures,
|
||||
);
|
||||
if chunk_result.chunk.retry_count < MAX_RETRY_COUNT {
|
||||
let mut retry_chunk = chunk_result.chunk;
|
||||
retry_chunk.retry_count += 1;
|
||||
retry_chunk.last_peer = Some(chunk_result.peer_addr);
|
||||
failed_chunks.push(retry_chunk);
|
||||
} else {
|
||||
*last_err = Some(eyre::eyre!(
|
||||
"Max retries exceeded for chunk: {}",
|
||||
chunk_result.chunk.request_path
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_initial_chunk_result(
|
||||
ctx: &TransferContext<'_>,
|
||||
source: &PeerEndpoint,
|
||||
result: ChunkDownloadResult,
|
||||
failed_chunks: &mut Vec<RetryChunk>,
|
||||
failures: &mut TransferFailureAggregate,
|
||||
) {
|
||||
match result.result {
|
||||
Ok(()) => notify_chunk_finished(ctx, &result.chunk, result.peer_endpoint),
|
||||
Err(error) => {
|
||||
log::warn!("Failed to download chunk from {}: {error}", source.addr);
|
||||
quarantine_if_integrity_failure(ctx.quarantine, source, ctx.content_id, &error);
|
||||
match error.kind() {
|
||||
DownloadTransferErrorKind::Integrity | DownloadTransferErrorKind::Transport => {
|
||||
failed_chunks.push(RetryChunk::after_failure(result.chunk, *source, error));
|
||||
}
|
||||
DownloadTransferErrorKind::LocalIo | DownloadTransferErrorKind::Cancelled => {
|
||||
failures.record(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn notify_chunk_finished(
|
||||
ctx: &TransferContext<'_>,
|
||||
chunk: &DownloadChunk,
|
||||
peer_endpoint: PeerEndpoint,
|
||||
) {
|
||||
let _ = ctx
|
||||
.tx_notify_ui
|
||||
.send(PeerEvent::DownloadGameFileChunkFinished {
|
||||
id: ctx.game_id.to_string(),
|
||||
peer_id: peer_endpoint.peer_id,
|
||||
peer_addr: peer_endpoint.addr,
|
||||
content_id: chunk.content_id,
|
||||
relative_path: chunk.canonical_path().clone(),
|
||||
offset: chunk.offset,
|
||||
length: chunk.length,
|
||||
});
|
||||
}
|
||||
|
||||
async fn retry_chunks(
|
||||
ctx: &TransferContext<'_>,
|
||||
failed_chunks: Vec<DownloadChunk>,
|
||||
last_err: &mut Option<eyre::Report>,
|
||||
) -> eyre::Result<()> {
|
||||
failed_chunks: Vec<RetryChunk>,
|
||||
failures: &mut TransferFailureAggregate,
|
||||
) -> Result<(), DownloadOperationError> {
|
||||
if ctx.cancel_token.is_cancelled() {
|
||||
eyre::bail!("download cancelled for game {}", ctx.game_id);
|
||||
return Err(failures.cancellation_error(ctx.game_id));
|
||||
}
|
||||
|
||||
log::info!("Retrying {} failed chunks", failed_chunks.len());
|
||||
|
||||
let retry_ctx = RetryContext {
|
||||
peers: ctx.peers,
|
||||
sources: ctx.sources,
|
||||
content_id: ctx.content_id,
|
||||
quarantine: ctx.quarantine,
|
||||
game_root: ctx.game_root,
|
||||
game_id: ctx.game_id,
|
||||
file_peer_map: ctx.file_peer_map,
|
||||
cancel_token: ctx.cancel_token,
|
||||
version_buffer: Some(ctx.version_buffer.clone()),
|
||||
quic: ctx.quic,
|
||||
version_buffer: ctx.version_buffer.clone(),
|
||||
progress_tracker: ctx.progress_tracker.clone(),
|
||||
attempt: ctx.attempt.clone(),
|
||||
};
|
||||
let retry_results = match retry_failed_chunks(failed_chunks, &retry_ctx).await {
|
||||
Ok(results) => results,
|
||||
Err(_) if ctx.cancel_token.is_cancelled() => {
|
||||
eyre::bail!("download cancelled for game {}", ctx.game_id);
|
||||
return Err(failures.cancellation_error(ctx.game_id));
|
||||
}
|
||||
Err(err) => {
|
||||
*last_err = Some(err);
|
||||
Vec::new()
|
||||
return Err(DownloadOperationError::operation_failed(err));
|
||||
}
|
||||
};
|
||||
|
||||
for chunk_result in retry_results {
|
||||
if ctx.cancel_token.is_cancelled() {
|
||||
eyre::bail!("download cancelled for game {}", ctx.game_id);
|
||||
return Err(failures.cancellation_error(ctx.game_id));
|
||||
}
|
||||
|
||||
match chunk_result.result {
|
||||
Ok(()) => {
|
||||
let _ = ctx
|
||||
.tx_notify_ui
|
||||
.send(PeerEvent::DownloadGameFileChunkFinished {
|
||||
id: ctx.game_id.to_string(),
|
||||
peer_addr: chunk_result.peer_addr,
|
||||
relative_path: chunk_result.chunk.request_path,
|
||||
offset: chunk_result.chunk.offset,
|
||||
length: chunk_result.chunk.length,
|
||||
});
|
||||
notify_chunk_finished(ctx, &chunk_result.chunk, chunk_result.peer_endpoint);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Retry failed for chunk: {e}");
|
||||
*last_err = Some(e);
|
||||
failures.record(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -364,3 +648,71 @@ fn total_download_bytes(file_descs: &[ValidatedDownloadEntry]) -> u64 {
|
||||
.filter(|entry| !entry.is_dir())
|
||||
.fold(0u64, |total, entry| total.saturating_add(entry.size()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn source(peer_id: &str, port: u16) -> PeerEndpoint {
|
||||
PeerEndpoint::new(
|
||||
PeerId::from_bytes(*blake3::hash(peer_id.as_bytes()).as_bytes()),
|
||||
SocketAddr::from(([127, 0, 0, 1], port)),
|
||||
)
|
||||
}
|
||||
|
||||
fn content(seed: u8) -> ContentId {
|
||||
ContentId::from_bytes([seed; 32])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_source_filter_skips_bad_source_and_keeps_good_source() {
|
||||
let bad = source("bad", 12000);
|
||||
let good = source("good", 12001);
|
||||
let sources = vec![bad, good];
|
||||
let quarantine = ContentQuarantine::default();
|
||||
let content_id = content(1);
|
||||
quarantine.record_integrity_failure(&bad, content_id);
|
||||
|
||||
let eligible = eligible_content_sources(&sources, content_id, &quarantine)
|
||||
.expect("unambiguous content sources should validate");
|
||||
|
||||
assert_eq!(eligible, vec![good]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_source_filter_rejects_ambiguous_address_identity() {
|
||||
let sources = vec![source("first", 12000), source("second", 12000)];
|
||||
|
||||
let error = eligible_content_sources(&sources, content(2), &ContentQuarantine::default())
|
||||
.expect_err("one address must not represent two authenticated identities");
|
||||
|
||||
assert!(error.to_string().contains("ambiguously assigned"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_failure_is_not_downgraded_by_later_retryable_exhaustion() {
|
||||
let mut failures = TransferFailureAggregate::default();
|
||||
failures.record(DownloadTransferError::local_io("destination write failed"));
|
||||
failures.record(DownloadTransferError::transport(
|
||||
"retry source disconnected",
|
||||
));
|
||||
|
||||
let error = failures
|
||||
.into_operation_error()
|
||||
.expect("recorded failures should produce an operation error");
|
||||
|
||||
assert_eq!(error.reason(), Some(DownloadFailureReason::OperationFailed));
|
||||
assert_eq!(error.to_string(), "destination write failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_failure_is_not_downgraded_by_later_cancellation() {
|
||||
let mut failures = TransferFailureAggregate::default();
|
||||
failures.record(DownloadTransferError::local_io("destination write failed"));
|
||||
|
||||
let error = failures.cancellation_error("game");
|
||||
|
||||
assert_eq!(error.reason(), Some(DownloadFailureReason::OperationFailed));
|
||||
assert_eq!(error.to_string(), "destination write failed");
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,24 @@
|
||||
//! Crash-consistent provenance for files created by peer downloads.
|
||||
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
io::ErrorKind,
|
||||
collections::{BTreeSet, HashMap, HashSet},
|
||||
io::{ErrorKind, Read as _, Write as _},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use cap_fs_ext::{
|
||||
FollowSymlinks,
|
||||
OpenOptionsFollowExt,
|
||||
OpenOptionsMaybeDirExt,
|
||||
OpenOptionsSyncExt,
|
||||
};
|
||||
use cap_primitives::{
|
||||
ambient_authority,
|
||||
fs::{self as cap_fs, OpenOptions as CapOpenOptions},
|
||||
};
|
||||
use eyre::WrapErr as _;
|
||||
use lanspread_db::content_manifest::ContentId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use super::{
|
||||
confined_fs::ConfinedGameRoot,
|
||||
@@ -28,12 +38,49 @@ use super::{
|
||||
},
|
||||
};
|
||||
use crate::{
|
||||
game_paths::{BACKUP_DIR, INSTALLING_DIR, LOCAL_DIR, VERSION_INI},
|
||||
state_paths::{download_ownership_path, download_ownership_tmp_path},
|
||||
game_paths::{BACKUP_DIR, INSTALLING_DIR, LOCAL_DIR, VERSION_INI, portable_name_key},
|
||||
scoped_blocking::scoped_blocking,
|
||||
state_paths::{
|
||||
DOWNLOAD_OWNERSHIP_DIR,
|
||||
DOWNLOAD_OWNERSHIP_RECORD_FILE,
|
||||
DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE,
|
||||
DOWNLOAD_OWNERSHIP_TMP_FILE,
|
||||
LEGACY_DOWNLOAD_OWNERSHIP_FILE,
|
||||
LEGACY_DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE,
|
||||
LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE,
|
||||
download_ownership_namespace_component,
|
||||
download_ownership_namespace_dir,
|
||||
download_ownership_path,
|
||||
download_ownership_recovery_required_path,
|
||||
download_ownership_tmp_path,
|
||||
games_folder_key,
|
||||
games_state_dir,
|
||||
legacy_download_ownership_path,
|
||||
legacy_download_ownership_recovery_required_path,
|
||||
legacy_download_ownership_tmp_path,
|
||||
},
|
||||
};
|
||||
|
||||
const OWNERSHIP_SCHEMA_VERSION: u32 = 1;
|
||||
const OWNERSHIP_SCHEMA_VERSION: u32 = 2;
|
||||
const MAX_OWNERSHIP_RECORD_BYTES: u64 = 128 * 1024 * 1024;
|
||||
const MAX_DOWNLOAD_OWNERSHIP_GAME_DIRS: usize = 100_000;
|
||||
const MAX_DOWNLOAD_OWNERSHIP_GAME_STATE_ENTRIES: usize = 100_000;
|
||||
const MAX_DOWNLOAD_OWNERSHIP_NAMESPACES: usize = 100_000;
|
||||
const MAX_DOWNLOAD_OWNERSHIP_NAMESPACE_ENTRIES: usize = 3;
|
||||
const RECOVERY_MARKER_BYTES: &[u8] = b"recovery required\n";
|
||||
|
||||
/// Establishes a cancellation point before entering finite ownership I/O.
|
||||
///
|
||||
/// Once the closure starts, it runs to completion in the calling task's
|
||||
/// lexical scope. Aborting that task therefore cannot detach an in-progress
|
||||
/// filesystem mutation or let the caller observe half of an atomic sequence.
|
||||
async fn scoped_ownership_fs<F, R>(work: F) -> R
|
||||
where
|
||||
F: FnOnce() -> R,
|
||||
{
|
||||
tokio::task::yield_now().await;
|
||||
scoped_blocking(work)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -41,7 +88,9 @@ struct DownloadOwnershipRecord {
|
||||
schema_version: u32,
|
||||
game_id: String,
|
||||
games_folder_key: String,
|
||||
committed_content_id: Option<ContentId>,
|
||||
committed_files: Vec<String>,
|
||||
pending_content_id: Option<ContentId>,
|
||||
pending_files: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -51,7 +100,9 @@ impl DownloadOwnershipRecord {
|
||||
schema_version: OWNERSHIP_SCHEMA_VERSION,
|
||||
game_id: game_id.to_owned(),
|
||||
games_folder_key: games_folder_key.to_owned(),
|
||||
committed_content_id: None,
|
||||
committed_files: Vec::new(),
|
||||
pending_content_id: None,
|
||||
pending_files: None,
|
||||
}
|
||||
}
|
||||
@@ -77,12 +128,20 @@ impl DownloadOwnershipRecord {
|
||||
eyre::bail!("download ownership belongs to a different games directory");
|
||||
}
|
||||
validate_file_set(&self.committed_files)?;
|
||||
if self.committed_content_id.is_none() && !self.committed_files.is_empty() {
|
||||
eyre::bail!("download ownership contains unverified committed files");
|
||||
}
|
||||
if let Some(pending) = &self.pending_files {
|
||||
validate_file_set(pending)?;
|
||||
if self.pending_content_id.is_none() && !pending.is_empty() {
|
||||
eyre::bail!("download ownership contains unverified pending files");
|
||||
}
|
||||
validate_generation_aliases(
|
||||
&self.committed_files.iter().cloned().collect(),
|
||||
&pending.iter().cloned().collect(),
|
||||
)?;
|
||||
} else if self.pending_content_id.is_some() {
|
||||
eyre::bail!("download ownership contains a pending content ID without pending files");
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
@@ -90,15 +149,762 @@ impl DownloadOwnershipRecord {
|
||||
|
||||
enum LoadedOwnership {
|
||||
Missing,
|
||||
Foreign,
|
||||
Invalid,
|
||||
Valid(DownloadOwnershipRecord),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct OwnershipNamespaceArtifacts {
|
||||
marker_exists: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct LegacyOwnershipState {
|
||||
record: Option<DownloadOwnershipRecord>,
|
||||
marker_exists: bool,
|
||||
tmp_exists: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ScannedOwnershipNamespace {
|
||||
record: Option<DownloadOwnershipRecord>,
|
||||
tmp_exists: bool,
|
||||
}
|
||||
|
||||
/// Finds ownership state bound to one configured games directory.
|
||||
///
|
||||
/// The scan validates the selected ownership namespace before returning any
|
||||
/// IDs. It never mutates state and never follows a link or Windows reparse
|
||||
/// point. Namespaces for other roots remain inert and uninspected.
|
||||
pub(crate) fn scan_download_ownership_recovery_ids(
|
||||
state_dir: &Path,
|
||||
games_folder: &Path,
|
||||
) -> eyre::Result<HashSet<String>> {
|
||||
scoped_blocking(|| {
|
||||
let games_folder = canonical_games_folder(games_folder)?;
|
||||
open_ambient_directory_nofollow(&games_folder).wrap_err_with(|| {
|
||||
format!(
|
||||
"configured games path is not a safe directory: {}",
|
||||
games_folder.display()
|
||||
)
|
||||
})?;
|
||||
scan_download_ownership_recovery_ids_blocking(
|
||||
&games_state_dir(state_dir),
|
||||
&games_folder_key(&games_folder),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_download_ownership_recovery_ids_blocking(
|
||||
state_games_dir: &Path,
|
||||
expected_games_folder_key: &str,
|
||||
) -> eyre::Result<HashSet<String>> {
|
||||
let state_games = match open_ambient_directory_nofollow(state_games_dir) {
|
||||
Ok(directory) => directory,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(HashSet::new()),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
|
||||
let mut recovery_ids = HashSet::new();
|
||||
let mut portable_ids = HashMap::new();
|
||||
let mut game_count = 0_usize;
|
||||
let mut game_state_entry_count = 0_usize;
|
||||
let mut namespace_count = 0_usize;
|
||||
for entry in cap_fs::read_base_dir(&state_games)? {
|
||||
game_count += 1;
|
||||
if game_count > MAX_DOWNLOAD_OWNERSHIP_GAME_DIRS {
|
||||
eyre::bail!(
|
||||
"download ownership state contains more than {MAX_DOWNLOAD_OWNERSHIP_GAME_DIRS} game directories"
|
||||
);
|
||||
}
|
||||
|
||||
let entry = entry.wrap_err("failed to enumerate download ownership state")?;
|
||||
let name = entry.file_name();
|
||||
let display_path = state_games_dir.join(&name);
|
||||
let game_state = open_directory_at(&state_games, Path::new(&name)).wrap_err_with(|| {
|
||||
format!(
|
||||
"unsafe download ownership game-state directory {}",
|
||||
display_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let legacy_present = legacy_ownership_artifacts_exist(&game_state)?;
|
||||
let namespaces =
|
||||
find_ownership_namespaces_dir(&game_state, &display_path, &mut game_state_entry_count)?;
|
||||
if !legacy_present && namespaces.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let id = name.to_str().ok_or_else(|| {
|
||||
eyre::eyre!(
|
||||
"download ownership game-state directory is not valid UTF-8: {}",
|
||||
display_path.display()
|
||||
)
|
||||
})?;
|
||||
validate_game_id(id).wrap_err_with(|| {
|
||||
format!(
|
||||
"invalid game ID for download ownership {}",
|
||||
display_path.display()
|
||||
)
|
||||
})?;
|
||||
let portable_id = portable_name_key(id);
|
||||
if let Some(previous) = portable_ids.insert(portable_id, id.to_owned()) {
|
||||
eyre::bail!("download ownership IDs {previous:?} and {id:?} are portable aliases");
|
||||
}
|
||||
|
||||
let legacy = read_legacy_ownership_state_at(&game_state, id)?;
|
||||
if legacy.record.is_some() || legacy.tmp_exists {
|
||||
recovery_ids.insert(id.to_owned());
|
||||
}
|
||||
|
||||
let Some(namespaces) = namespaces else {
|
||||
continue;
|
||||
};
|
||||
let expected_namespace = download_ownership_namespace_component(expected_games_folder_key);
|
||||
if let Some(namespace_dir) = find_selected_namespace(
|
||||
&namespaces,
|
||||
&display_path.join(DOWNLOAD_OWNERSHIP_DIR),
|
||||
&expected_namespace,
|
||||
&mut namespace_count,
|
||||
)? {
|
||||
let scanned =
|
||||
scan_download_ownership_namespace(&namespace_dir, id, &expected_namespace)?;
|
||||
if scanned.tmp_exists {
|
||||
recovery_ids.insert(id.to_owned());
|
||||
}
|
||||
let Some(record) = scanned.record else {
|
||||
continue;
|
||||
};
|
||||
if record.games_folder_key != expected_games_folder_key {
|
||||
eyre::bail!(
|
||||
"download ownership namespace for {id} contains a record bound to a different games directory"
|
||||
);
|
||||
}
|
||||
if let Some(legacy_record) = legacy.record
|
||||
&& legacy_record.games_folder_key == expected_games_folder_key
|
||||
&& legacy_record != record
|
||||
{
|
||||
eyre::bail!("legacy and namespaced download ownership records conflict for {id}");
|
||||
}
|
||||
recovery_ids.insert(id.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(recovery_ids)
|
||||
}
|
||||
|
||||
fn find_ownership_namespaces_dir(
|
||||
game_state: &std::fs::File,
|
||||
display_path: &Path,
|
||||
entry_count: &mut usize,
|
||||
) -> eyre::Result<Option<std::fs::File>> {
|
||||
let expected_alias = portable_name_key(DOWNLOAD_OWNERSHIP_DIR);
|
||||
let mut found = false;
|
||||
for entry in cap_fs::read_base_dir(game_state)? {
|
||||
*entry_count += 1;
|
||||
if *entry_count > MAX_DOWNLOAD_OWNERSHIP_GAME_STATE_ENTRIES {
|
||||
eyre::bail!(
|
||||
"download ownership state contains more than {MAX_DOWNLOAD_OWNERSHIP_GAME_STATE_ENTRIES} per-game state entries"
|
||||
);
|
||||
}
|
||||
let entry = entry.wrap_err("failed to enumerate game ownership state")?;
|
||||
let name = entry.file_name();
|
||||
let Some(name_str) = name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
for legacy_name in [
|
||||
LEGACY_DOWNLOAD_OWNERSHIP_FILE,
|
||||
LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE,
|
||||
LEGACY_DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE,
|
||||
] {
|
||||
if name_str != legacy_name
|
||||
&& portable_name_key(name_str) == portable_name_key(legacy_name)
|
||||
{
|
||||
eyre::bail!(
|
||||
"legacy download ownership entry {} is a portable alias of {legacy_name:?}",
|
||||
display_path.join(&name).display()
|
||||
);
|
||||
}
|
||||
}
|
||||
if portable_name_key(name_str) == expected_alias {
|
||||
if name_str != DOWNLOAD_OWNERSHIP_DIR {
|
||||
eyre::bail!(
|
||||
"download ownership namespace directory {} is a portable alias of {DOWNLOAD_OWNERSHIP_DIR:?}",
|
||||
display_path.join(&name).display()
|
||||
);
|
||||
}
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Ok(None);
|
||||
}
|
||||
open_directory_at(game_state, Path::new(DOWNLOAD_OWNERSHIP_DIR))
|
||||
.map(Some)
|
||||
.wrap_err_with(|| {
|
||||
format!(
|
||||
"unsafe download ownership namespace directory {}",
|
||||
display_path.join(DOWNLOAD_OWNERSHIP_DIR).display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn find_selected_namespace(
|
||||
namespaces: &std::fs::File,
|
||||
display_path: &Path,
|
||||
expected_namespace: &str,
|
||||
namespace_count: &mut usize,
|
||||
) -> eyre::Result<Option<std::fs::File>> {
|
||||
let expected_alias = portable_name_key(expected_namespace);
|
||||
let mut found = false;
|
||||
for entry in cap_fs::read_base_dir(namespaces)? {
|
||||
*namespace_count += 1;
|
||||
if *namespace_count > MAX_DOWNLOAD_OWNERSHIP_NAMESPACES {
|
||||
eyre::bail!(
|
||||
"download ownership state contains more than {MAX_DOWNLOAD_OWNERSHIP_NAMESPACES} root namespaces"
|
||||
);
|
||||
}
|
||||
let entry = entry.wrap_err("failed to enumerate download ownership root namespaces")?;
|
||||
let name = entry.file_name();
|
||||
let Some(name_str) = name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
if name_str == expected_namespace {
|
||||
found = true;
|
||||
} else if portable_name_key(name_str) == expected_alias {
|
||||
eyre::bail!(
|
||||
"download ownership namespace {} is a portable alias of {expected_namespace:?}",
|
||||
display_path.join(&name).display()
|
||||
);
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Ok(None);
|
||||
}
|
||||
open_directory_at(namespaces, Path::new(expected_namespace))
|
||||
.map(Some)
|
||||
.wrap_err_with(|| {
|
||||
format!(
|
||||
"unsafe download ownership namespace {}",
|
||||
display_path.join(expected_namespace).display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_download_ownership_namespace(
|
||||
namespace_dir: &std::fs::File,
|
||||
expected_game_id: &str,
|
||||
namespace: &str,
|
||||
) -> eyre::Result<ScannedOwnershipNamespace> {
|
||||
let mut record_file = None;
|
||||
let mut marker_exists = false;
|
||||
let mut tmp_exists = false;
|
||||
let mut entry_count = 0_usize;
|
||||
for entry in cap_fs::read_base_dir(namespace_dir)? {
|
||||
entry_count += 1;
|
||||
if entry_count > MAX_DOWNLOAD_OWNERSHIP_NAMESPACE_ENTRIES {
|
||||
eyre::bail!(
|
||||
"download ownership namespace {namespace} contains more than {MAX_DOWNLOAD_OWNERSHIP_NAMESPACE_ENTRIES} entries"
|
||||
);
|
||||
}
|
||||
|
||||
let entry = entry.wrap_err("failed to enumerate a download ownership namespace")?;
|
||||
let name = entry.file_name();
|
||||
let Some(name_str) = name.to_str() else {
|
||||
eyre::bail!("download ownership namespace {namespace} contains a non-UTF-8 entry");
|
||||
};
|
||||
let file = open_regular_file_at(namespace_dir, Path::new(&name)).wrap_err_with(|| {
|
||||
format!("unsafe download ownership namespace entry {namespace}/{name_str}")
|
||||
})?;
|
||||
match name_str {
|
||||
DOWNLOAD_OWNERSHIP_RECORD_FILE => record_file = Some(file),
|
||||
DOWNLOAD_OWNERSHIP_TMP_FILE => {
|
||||
validate_file_size(
|
||||
&file,
|
||||
MAX_OWNERSHIP_RECORD_BYTES,
|
||||
"ownership temporary file",
|
||||
)?;
|
||||
tmp_exists = true;
|
||||
}
|
||||
DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE => {
|
||||
let marker = read_bounded_file(file, u64::try_from(RECOVERY_MARKER_BYTES.len())?)?;
|
||||
if marker != RECOVERY_MARKER_BYTES {
|
||||
eyre::bail!("download ownership recovery marker has invalid contents");
|
||||
}
|
||||
marker_exists = true;
|
||||
}
|
||||
_ => eyre::bail!(
|
||||
"download ownership namespace {namespace} contains unexpected entry {name_str:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
let Some(record_file) = record_file else {
|
||||
if marker_exists {
|
||||
eyre::bail!(
|
||||
"download ownership namespace {namespace} has a recovery marker but no ownership record"
|
||||
);
|
||||
}
|
||||
return Ok(ScannedOwnershipNamespace {
|
||||
record: None,
|
||||
tmp_exists,
|
||||
});
|
||||
};
|
||||
let record = load_scanned_ownership_record(record_file, expected_game_id)?;
|
||||
let expected_namespace = download_ownership_namespace_component(&record.games_folder_key);
|
||||
if namespace != expected_namespace {
|
||||
eyre::bail!(
|
||||
"download ownership namespace {namespace} does not match its bound games directory"
|
||||
);
|
||||
}
|
||||
Ok(ScannedOwnershipNamespace {
|
||||
record: Some(record),
|
||||
tmp_exists,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_scanned_ownership_record(
|
||||
file: std::fs::File,
|
||||
expected_game_id: &str,
|
||||
) -> eyre::Result<DownloadOwnershipRecord> {
|
||||
let bytes = read_bounded_file(file, MAX_OWNERSHIP_RECORD_BYTES)?;
|
||||
let record: DownloadOwnershipRecord = serde_json::from_slice(&bytes)?;
|
||||
let record_games_folder_key = record.games_folder_key.clone();
|
||||
let record = record.validate(expected_game_id, &record_games_folder_key)?;
|
||||
validate_persisted_games_folder_key(&record_games_folder_key)?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn legacy_ownership_artifacts_exist(game_state: &std::fs::File) -> eyre::Result<bool> {
|
||||
for name in [
|
||||
LEGACY_DOWNLOAD_OWNERSHIP_FILE,
|
||||
LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE,
|
||||
LEGACY_DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE,
|
||||
] {
|
||||
match open_regular_file_at(game_state, Path::new(name)) {
|
||||
Ok(_) => return Ok(true),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn read_legacy_ownership_state_at(
|
||||
game_state: &std::fs::File,
|
||||
game_id: &str,
|
||||
) -> eyre::Result<LegacyOwnershipState> {
|
||||
let record_file =
|
||||
match open_regular_file_at(game_state, Path::new(LEGACY_DOWNLOAD_OWNERSHIP_FILE)) {
|
||||
Ok(file) => Some(file),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let tmp_file =
|
||||
match open_regular_file_at(game_state, Path::new(LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE)) {
|
||||
Ok(file) => Some(file),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if let Some(file) = &tmp_file {
|
||||
validate_file_size(
|
||||
file,
|
||||
MAX_OWNERSHIP_RECORD_BYTES,
|
||||
"legacy ownership temporary file",
|
||||
)?;
|
||||
}
|
||||
let tmp_exists = tmp_file.is_some();
|
||||
let marker_file = match open_regular_file_at(
|
||||
game_state,
|
||||
Path::new(LEGACY_DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE),
|
||||
) {
|
||||
Ok(file) => Some(file),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => None,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let marker_exists = marker_file.is_some();
|
||||
if let Some(file) = marker_file {
|
||||
let marker = read_bounded_file(file, u64::try_from(RECOVERY_MARKER_BYTES.len())?)?;
|
||||
if marker != RECOVERY_MARKER_BYTES {
|
||||
eyre::bail!("legacy download ownership recovery marker has invalid contents");
|
||||
}
|
||||
}
|
||||
let Some(record_file) = record_file else {
|
||||
if marker_exists {
|
||||
eyre::bail!(
|
||||
"legacy download ownership recovery marker exists without an ownership record"
|
||||
);
|
||||
}
|
||||
return Ok(LegacyOwnershipState {
|
||||
record: None,
|
||||
marker_exists: false,
|
||||
tmp_exists,
|
||||
});
|
||||
};
|
||||
Ok(LegacyOwnershipState {
|
||||
record: Some(load_scanned_ownership_record(record_file, game_id)?),
|
||||
marker_exists,
|
||||
tmp_exists,
|
||||
})
|
||||
}
|
||||
|
||||
fn inspect_ownership_namespace(path: &Path) -> eyre::Result<OwnershipNamespaceArtifacts> {
|
||||
let namespace_name = path
|
||||
.file_name()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.ok_or_else(|| eyre::eyre!("download ownership namespace path has no UTF-8 name"))?;
|
||||
let namespaces_path = path
|
||||
.parent()
|
||||
.ok_or_else(|| eyre::eyre!("download ownership namespace path has no parent"))?;
|
||||
let game_state_path = namespaces_path
|
||||
.parent()
|
||||
.ok_or_else(|| eyre::eyre!("download ownership namespace path has no game state"))?;
|
||||
let state_games_path = game_state_path
|
||||
.parent()
|
||||
.ok_or_else(|| eyre::eyre!("download ownership namespace path has no state root"))?;
|
||||
let game_name = game_state_path
|
||||
.file_name()
|
||||
.ok_or_else(|| eyre::eyre!("download ownership game-state path has no name"))?;
|
||||
let state_games = match open_ambient_directory_nofollow(state_games_path) {
|
||||
Ok(directory) => directory,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
return Ok(OwnershipNamespaceArtifacts::default());
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(error).wrap_err_with(|| {
|
||||
format!("unsafe download ownership namespace {}", path.display())
|
||||
});
|
||||
}
|
||||
};
|
||||
let game_state = match open_directory_at(&state_games, Path::new(game_name)) {
|
||||
Ok(directory) => directory,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
return Ok(OwnershipNamespaceArtifacts::default());
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let mut game_state_entry_count = 0;
|
||||
let Some(namespaces) =
|
||||
find_ownership_namespaces_dir(&game_state, game_state_path, &mut game_state_entry_count)?
|
||||
else {
|
||||
return Ok(OwnershipNamespaceArtifacts::default());
|
||||
};
|
||||
let mut namespace_count = 0;
|
||||
let Some(namespace_dir) = find_selected_namespace(
|
||||
&namespaces,
|
||||
namespaces_path,
|
||||
namespace_name,
|
||||
&mut namespace_count,
|
||||
)?
|
||||
else {
|
||||
return Ok(OwnershipNamespaceArtifacts::default());
|
||||
};
|
||||
|
||||
let mut artifacts = OwnershipNamespaceArtifacts::default();
|
||||
let mut entry_count = 0_usize;
|
||||
for entry in cap_fs::read_base_dir(&namespace_dir)? {
|
||||
entry_count += 1;
|
||||
if entry_count > MAX_DOWNLOAD_OWNERSHIP_NAMESPACE_ENTRIES {
|
||||
eyre::bail!(
|
||||
"download ownership namespace {} contains more than {MAX_DOWNLOAD_OWNERSHIP_NAMESPACE_ENTRIES} entries",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
let entry = entry.wrap_err("failed to enumerate download ownership namespace")?;
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_str().ok_or_else(|| {
|
||||
eyre::eyre!(
|
||||
"download ownership namespace {} contains a non-UTF-8 entry",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let file = open_regular_file_at(&namespace_dir, Path::new(&name)).wrap_err_with(|| {
|
||||
format!(
|
||||
"unsafe download ownership namespace entry {}",
|
||||
path.join(&name).display()
|
||||
)
|
||||
})?;
|
||||
match name_str {
|
||||
DOWNLOAD_OWNERSHIP_RECORD_FILE => {
|
||||
validate_file_size(&file, MAX_OWNERSHIP_RECORD_BYTES, "ownership record")?;
|
||||
}
|
||||
DOWNLOAD_OWNERSHIP_TMP_FILE => {
|
||||
validate_file_size(
|
||||
&file,
|
||||
MAX_OWNERSHIP_RECORD_BYTES,
|
||||
"ownership temporary file",
|
||||
)?;
|
||||
}
|
||||
DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE => {
|
||||
let marker = read_bounded_file(file, u64::try_from(RECOVERY_MARKER_BYTES.len())?)?;
|
||||
if marker != RECOVERY_MARKER_BYTES {
|
||||
eyre::bail!("download ownership recovery marker has invalid contents");
|
||||
}
|
||||
artifacts.marker_exists = true;
|
||||
}
|
||||
_ => eyre::bail!(
|
||||
"download ownership namespace {} contains unexpected entry {name_str:?}",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(artifacts)
|
||||
}
|
||||
|
||||
fn load_legacy_ownership_state(
|
||||
state_dir: &Path,
|
||||
game_id: &str,
|
||||
) -> eyre::Result<LegacyOwnershipState> {
|
||||
let state_games = match open_ambient_directory_nofollow(&games_state_dir(state_dir)) {
|
||||
Ok(directory) => directory,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
return Ok(LegacyOwnershipState::default());
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let game_state = match open_directory_at(&state_games, Path::new(game_id)) {
|
||||
Ok(directory) => directory,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||
return Ok(LegacyOwnershipState::default());
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let mut entry_count = 0;
|
||||
let _ = find_ownership_namespaces_dir(
|
||||
&game_state,
|
||||
&games_state_dir(state_dir).join(game_id),
|
||||
&mut entry_count,
|
||||
)?;
|
||||
read_legacy_ownership_state_at(&game_state, game_id).wrap_err_with(|| {
|
||||
format!(
|
||||
"invalid legacy download ownership state at {}, {}, or {}",
|
||||
legacy_download_ownership_path(state_dir, game_id).display(),
|
||||
legacy_download_ownership_tmp_path(state_dir, game_id).display(),
|
||||
legacy_download_ownership_recovery_required_path(state_dir, game_id).display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn migrate_current_legacy_ownership(state_dir: &Path, game_id: &str) -> eyre::Result<()> {
|
||||
let legacy = load_legacy_ownership_state(state_dir, game_id)?;
|
||||
let Some(record) = legacy.record else {
|
||||
if legacy.tmp_exists {
|
||||
remove_legacy_ownership_tmp(state_dir, game_id)?;
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
let games_folder_key = &record.games_folder_key;
|
||||
|
||||
let namespace_path = download_ownership_namespace_dir(state_dir, game_id, games_folder_key);
|
||||
let record_path = download_ownership_path(state_dir, game_id, games_folder_key);
|
||||
let tmp_path = download_ownership_tmp_path(state_dir, game_id, games_folder_key);
|
||||
let marker_path =
|
||||
download_ownership_recovery_required_path(state_dir, game_id, games_folder_key);
|
||||
let artifacts = inspect_ownership_namespace(&namespace_path)?;
|
||||
let needs_marker = legacy.marker_exists || record.pending_files.is_some();
|
||||
match load_record(&record_path, game_id, games_folder_key) {
|
||||
LoadedOwnership::Missing if artifacts.marker_exists => {
|
||||
eyre::bail!(
|
||||
"cannot migrate legacy ownership for {game_id}: destination has a marker without a record"
|
||||
);
|
||||
}
|
||||
LoadedOwnership::Missing => {
|
||||
sweep_tmp_file(&tmp_path);
|
||||
require_durable_record(
|
||||
write_record(&record_path, &tmp_path, &record)?,
|
||||
"migrated download ownership",
|
||||
)?;
|
||||
}
|
||||
LoadedOwnership::Valid(destination) if destination == record => {}
|
||||
LoadedOwnership::Valid(_) => {
|
||||
eyre::bail!(
|
||||
"cannot migrate legacy ownership for {game_id}: destination record conflicts"
|
||||
);
|
||||
}
|
||||
LoadedOwnership::Foreign => {
|
||||
eyre::bail!(
|
||||
"cannot migrate legacy ownership for {game_id}: destination record belongs to another games directory"
|
||||
);
|
||||
}
|
||||
LoadedOwnership::Invalid => {
|
||||
eyre::bail!(
|
||||
"cannot migrate legacy ownership for {game_id}: destination record is invalid"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let destination = inspect_ownership_namespace(&namespace_path)?;
|
||||
if needs_marker && !destination.marker_exists {
|
||||
create_recovery_marker(&marker_path)?;
|
||||
}
|
||||
sweep_tmp_file(&tmp_path);
|
||||
remove_legacy_ownership_after_migration(state_dir, game_id)
|
||||
}
|
||||
|
||||
fn remove_legacy_ownership_tmp(state_dir: &Path, game_id: &str) -> eyre::Result<()> {
|
||||
let state_games = open_ambient_directory_nofollow(&games_state_dir(state_dir))?;
|
||||
let game_state = open_directory_at(&state_games, Path::new(game_id))?;
|
||||
if remove_file_at_if_exists(&game_state, Path::new(LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE))? {
|
||||
sync_directory_handle(&game_state)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_legacy_ownership_after_migration(state_dir: &Path, game_id: &str) -> eyre::Result<()> {
|
||||
let state_games = open_ambient_directory_nofollow(&games_state_dir(state_dir))?;
|
||||
let game_state = open_directory_at(&state_games, Path::new(game_id))?;
|
||||
let removed_scratch =
|
||||
remove_file_at_if_exists(&game_state, Path::new(LEGACY_DOWNLOAD_OWNERSHIP_TMP_FILE))?
|
||||
| remove_file_at_if_exists(
|
||||
&game_state,
|
||||
Path::new(LEGACY_DOWNLOAD_OWNERSHIP_RECOVERY_REQUIRED_FILE),
|
||||
)?;
|
||||
if removed_scratch {
|
||||
sync_directory_handle(&game_state)?;
|
||||
}
|
||||
if remove_file_at_if_exists(&game_state, Path::new(LEGACY_DOWNLOAD_OWNERSHIP_FILE))? {
|
||||
sync_directory_handle(&game_state)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_file_at_if_exists(parent: &std::fs::File, path: &Path) -> std::io::Result<bool> {
|
||||
match cap_fs::remove_file(parent, path) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sync_directory_handle(directory: &std::fs::File) -> std::io::Result<()> {
|
||||
directory.sync_all()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
const fn sync_directory_handle(_directory: &std::fs::File) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_file_size(file: &std::fs::File, limit: u64, label: &str) -> eyre::Result<()> {
|
||||
let metadata = file.metadata()?;
|
||||
if metadata.len() > limit {
|
||||
eyre::bail!("{label} exceeds {limit} bytes");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_bounded_file(file: std::fs::File, limit: u64) -> eyre::Result<Vec<u8>> {
|
||||
validate_file_size(&file, limit, "download ownership file")?;
|
||||
let mut bytes = Vec::with_capacity(usize::try_from(file.metadata()?.len())?);
|
||||
file.take(limit + 1).read_to_end(&mut bytes)?;
|
||||
if u64::try_from(bytes.len())? > limit {
|
||||
eyre::bail!("download ownership file exceeds {limit} bytes");
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn validate_persisted_games_folder_key(key: &str) -> eyre::Result<()> {
|
||||
use std::{ffi::OsStr, os::unix::ffi::OsStrExt as _};
|
||||
|
||||
let bytes = decode_lower_hex_key(key, "unix:")?;
|
||||
if bytes.contains(&0) || !Path::new(OsStr::from_bytes(&bytes)).is_absolute() {
|
||||
eyre::bail!("download ownership contains an invalid games-directory key");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn validate_persisted_games_folder_key(key: &str) -> eyre::Result<()> {
|
||||
use std::os::windows::ffi::OsStringExt as _;
|
||||
|
||||
let encoded = key
|
||||
.strip_prefix("windows:")
|
||||
.ok_or_else(|| eyre::eyre!("download ownership contains an invalid games-directory key"))?;
|
||||
if encoded.is_empty() || encoded.len() % 4 != 0 || !is_lower_hex(encoded.as_bytes()) {
|
||||
eyre::bail!("download ownership contains an invalid games-directory key");
|
||||
}
|
||||
let units = encoded
|
||||
.as_bytes()
|
||||
.chunks_exact(4)
|
||||
.map(|chunk| {
|
||||
let digits = std::str::from_utf8(chunk)?;
|
||||
Ok(u16::from_str_radix(digits, 16)?)
|
||||
})
|
||||
.collect::<eyre::Result<Vec<_>>>()?;
|
||||
if units.contains(&0) || !PathBuf::from(std::ffi::OsString::from_wide(&units)).is_absolute() {
|
||||
eyre::bail!("download ownership contains an invalid games-directory key");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn validate_persisted_games_folder_key(key: &str) -> eyre::Result<()> {
|
||||
let _ = decode_lower_hex_key(key, "native:")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn decode_lower_hex_key(key: &str, prefix: &str) -> eyre::Result<Vec<u8>> {
|
||||
let encoded = key
|
||||
.strip_prefix(prefix)
|
||||
.ok_or_else(|| eyre::eyre!("download ownership contains an invalid games-directory key"))?;
|
||||
if encoded.is_empty() || encoded.len() % 2 != 0 || !is_lower_hex(encoded.as_bytes()) {
|
||||
eyre::bail!("download ownership contains an invalid games-directory key");
|
||||
}
|
||||
encoded
|
||||
.as_bytes()
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| {
|
||||
let digits = std::str::from_utf8(chunk)?;
|
||||
Ok(u8::from_str_radix(digits, 16)?)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_lower_hex(bytes: &[u8]) -> bool {
|
||||
bytes
|
||||
.iter()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum DownloadOwnershipReadiness {
|
||||
Untracked,
|
||||
Settled,
|
||||
RecoveryRequired,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct DownloadOwnershipStatus {
|
||||
readiness: DownloadOwnershipReadiness,
|
||||
committed_content_id: Option<ContentId>,
|
||||
}
|
||||
|
||||
impl DownloadOwnershipStatus {
|
||||
const fn without_content(readiness: DownloadOwnershipReadiness) -> Self {
|
||||
Self {
|
||||
readiness,
|
||||
committed_content_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
const fn settled(committed_content_id: Option<ContentId>) -> Self {
|
||||
Self {
|
||||
readiness: DownloadOwnershipReadiness::Settled,
|
||||
committed_content_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
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),
|
||||
NeedsRecovery(eyre::Report),
|
||||
}
|
||||
|
||||
/// One download attempt whose previous and proposed ownership sets are durable.
|
||||
@@ -106,13 +912,271 @@ pub(super) enum OwnershipJournalPublication {
|
||||
pub(super) struct DownloadOwnershipTransaction {
|
||||
record_path: PathBuf,
|
||||
tmp_path: PathBuf,
|
||||
recovery_required_path: PathBuf,
|
||||
game_id: String,
|
||||
games_folder_key: String,
|
||||
game_root: ConfinedGameRoot,
|
||||
previous_content_id: Option<ContentId>,
|
||||
previous: BTreeSet<String>,
|
||||
current_content_id: Option<ContentId>,
|
||||
current: BTreeSet<String>,
|
||||
}
|
||||
|
||||
struct DownloadRemovalPreparation {
|
||||
game_root: ConfinedGameRoot,
|
||||
game_id: String,
|
||||
games_folder_key: String,
|
||||
record_path: PathBuf,
|
||||
tmp_path: PathBuf,
|
||||
recovery_required_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Reports whether ownership metadata allows the current game root to be used.
|
||||
///
|
||||
/// Only a completely absent root namespace is untracked. Any structurally
|
||||
/// selected but unsettled, misplaced, legacy, or unreadable state fails closed
|
||||
/// until recovery has repaired it.
|
||||
pub(crate) async fn download_ownership_readiness(
|
||||
games_folder: &Path,
|
||||
state_dir: &Path,
|
||||
game_id: &str,
|
||||
) -> DownloadOwnershipReadiness {
|
||||
download_ownership_status(games_folder, state_dir, game_id)
|
||||
.await
|
||||
.readiness
|
||||
}
|
||||
|
||||
/// Returns whether settled ownership proves the exact expected catalog content.
|
||||
///
|
||||
/// Missing, legacy, corrupt, pending, recovery-marked, and differently bound
|
||||
/// records all fail closed. Callers may use this for local-download shortcuts;
|
||||
/// a version sentinel alone is not catalog-content proof.
|
||||
pub(crate) async fn download_ownership_matches_content(
|
||||
games_folder: &Path,
|
||||
state_dir: &Path,
|
||||
game_id: &str,
|
||||
expected_content_id: ContentId,
|
||||
) -> bool {
|
||||
let status = download_ownership_status(games_folder, state_dir, game_id).await;
|
||||
status.readiness == DownloadOwnershipReadiness::Settled
|
||||
&& status.committed_content_id == Some(expected_content_id)
|
||||
}
|
||||
|
||||
async fn download_ownership_status(
|
||||
games_folder: &Path,
|
||||
state_dir: &Path,
|
||||
game_id: &str,
|
||||
) -> DownloadOwnershipStatus {
|
||||
if validate_game_id(game_id).is_err() {
|
||||
return DownloadOwnershipStatus::without_content(
|
||||
DownloadOwnershipReadiness::RecoveryRequired,
|
||||
);
|
||||
}
|
||||
|
||||
scoped_ownership_fs(|| {
|
||||
let games_folder = match canonical_games_folder(games_folder) {
|
||||
Ok(games_folder) => games_folder,
|
||||
Err(error) => {
|
||||
log::warn!("Cannot resolve games directory for ownership readiness: {error}");
|
||||
return DownloadOwnershipStatus::without_content(
|
||||
DownloadOwnershipReadiness::RecoveryRequired,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let games_folder_key = games_folder_key(&games_folder);
|
||||
match load_legacy_ownership_state(state_dir, game_id) {
|
||||
Ok(legacy)
|
||||
if legacy
|
||||
.record
|
||||
.as_ref()
|
||||
.is_some_and(|record| record.games_folder_key == games_folder_key) =>
|
||||
{
|
||||
return DownloadOwnershipStatus::without_content(
|
||||
DownloadOwnershipReadiness::RecoveryRequired,
|
||||
);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => {
|
||||
log::warn!("Cannot inspect legacy download ownership state: {error}");
|
||||
return DownloadOwnershipStatus::without_content(
|
||||
DownloadOwnershipReadiness::RecoveryRequired,
|
||||
);
|
||||
}
|
||||
}
|
||||
let namespace_path =
|
||||
download_ownership_namespace_dir(state_dir, game_id, &games_folder_key);
|
||||
let artifacts = match inspect_ownership_namespace(&namespace_path) {
|
||||
Ok(artifacts) => artifacts,
|
||||
Err(error) => {
|
||||
log::warn!("Cannot inspect download ownership namespace: {error}");
|
||||
return DownloadOwnershipStatus::without_content(
|
||||
DownloadOwnershipReadiness::RecoveryRequired,
|
||||
);
|
||||
}
|
||||
};
|
||||
let record_path = download_ownership_path(state_dir, game_id, &games_folder_key);
|
||||
let record = load_record(&record_path, game_id, &games_folder_key);
|
||||
match record {
|
||||
LoadedOwnership::Missing if !artifacts.marker_exists => {
|
||||
DownloadOwnershipStatus::without_content(DownloadOwnershipReadiness::Untracked)
|
||||
}
|
||||
LoadedOwnership::Missing | LoadedOwnership::Foreign | LoadedOwnership::Invalid => {
|
||||
DownloadOwnershipStatus::without_content(
|
||||
DownloadOwnershipReadiness::RecoveryRequired,
|
||||
)
|
||||
}
|
||||
LoadedOwnership::Valid(DownloadOwnershipRecord {
|
||||
pending_files: Some(_),
|
||||
..
|
||||
}) => DownloadOwnershipStatus::without_content(
|
||||
DownloadOwnershipReadiness::RecoveryRequired,
|
||||
),
|
||||
LoadedOwnership::Valid(_) if artifacts.marker_exists => {
|
||||
DownloadOwnershipStatus::without_content(
|
||||
DownloadOwnershipReadiness::RecoveryRequired,
|
||||
)
|
||||
}
|
||||
LoadedOwnership::Valid(record) => {
|
||||
DownloadOwnershipStatus::settled(record.committed_content_id)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
impl DownloadRemovalPreparation {
|
||||
fn new(
|
||||
game_root: ConfinedGameRoot,
|
||||
state_dir: &Path,
|
||||
game_id: &str,
|
||||
games_folder_key: String,
|
||||
) -> Self {
|
||||
let record_path = download_ownership_path(state_dir, game_id, &games_folder_key);
|
||||
let tmp_path = download_ownership_tmp_path(state_dir, game_id, &games_folder_key);
|
||||
let recovery_required_path =
|
||||
download_ownership_recovery_required_path(state_dir, game_id, &games_folder_key);
|
||||
Self {
|
||||
game_root,
|
||||
game_id: game_id.to_owned(),
|
||||
games_folder_key,
|
||||
record_path,
|
||||
tmp_path,
|
||||
recovery_required_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_transaction_blocking(self) -> eyre::Result<Option<DownloadOwnershipTransaction>> {
|
||||
let record = match load_record(&self.record_path, &self.game_id, &self.games_folder_key) {
|
||||
LoadedOwnership::Valid(record) => record,
|
||||
LoadedOwnership::Missing => {
|
||||
eyre::bail!(
|
||||
"cannot safely remove downloaded files for {}: the ownership record is missing; move or delete the legacy game folder manually",
|
||||
self.game_id
|
||||
);
|
||||
}
|
||||
LoadedOwnership::Foreign => {
|
||||
eyre::bail!(
|
||||
"cannot safely remove downloaded files for {}: the ownership record belongs to a different games directory; move or delete the game folder manually",
|
||||
self.game_id
|
||||
);
|
||||
}
|
||||
LoadedOwnership::Invalid => {
|
||||
eyre::bail!(
|
||||
"cannot safely remove downloaded files for {}: the ownership record is invalid; move or delete the game folder manually",
|
||||
self.game_id
|
||||
);
|
||||
}
|
||||
};
|
||||
if record.pending_files.is_some() {
|
||||
eyre::bail!(
|
||||
"download ownership recovery did not settle for {}",
|
||||
self.game_id
|
||||
);
|
||||
}
|
||||
if !self.game_root.root_regular_file_exists(VERSION_INI)? {
|
||||
if !record.committed_files.is_empty() {
|
||||
eyre::bail!("download sentinel is missing for {}", self.game_id);
|
||||
}
|
||||
if record.committed_content_id.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
// A catalog generation may own only version.ini. If that sentinel
|
||||
// disappeared externally, removal still has to clear its
|
||||
// exact-content binding instead of leaving a false local shortcut
|
||||
// behind.
|
||||
}
|
||||
for (name, label) in [
|
||||
(LOCAL_DIR, "local install"),
|
||||
(INSTALLING_DIR, "install staging"),
|
||||
(BACKUP_DIR, "install backup"),
|
||||
] {
|
||||
if self.game_root.root_entry_exists(name)? {
|
||||
eyre::bail!(
|
||||
"refusing to remove downloaded files for {} with {label}",
|
||||
self.game_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(DownloadOwnershipTransaction {
|
||||
record_path: self.record_path,
|
||||
tmp_path: self.tmp_path,
|
||||
recovery_required_path: self.recovery_required_path,
|
||||
game_id: self.game_id,
|
||||
games_folder_key: self.games_folder_key,
|
||||
game_root: self.game_root,
|
||||
previous_content_id: record.committed_content_id,
|
||||
previous: record.committed_files.into_iter().collect(),
|
||||
current_content_id: None,
|
||||
current: BTreeSet::new(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_absent_download_ownership(
|
||||
state_dir: &Path,
|
||||
game_id: &str,
|
||||
games_folder_key: &str,
|
||||
) -> eyre::Result<()> {
|
||||
let namespace_path = download_ownership_namespace_dir(state_dir, game_id, games_folder_key);
|
||||
let artifacts = inspect_ownership_namespace(&namespace_path)?;
|
||||
let record_path = download_ownership_path(state_dir, game_id, games_folder_key);
|
||||
let tmp_path = download_ownership_tmp_path(state_dir, game_id, games_folder_key);
|
||||
let recovery_required_path =
|
||||
download_ownership_recovery_required_path(state_dir, game_id, games_folder_key);
|
||||
match load_record(&record_path, game_id, games_folder_key) {
|
||||
LoadedOwnership::Missing if !artifacts.marker_exists => {
|
||||
sweep_tmp_file(&tmp_path);
|
||||
Ok(())
|
||||
}
|
||||
LoadedOwnership::Missing => {
|
||||
eyre::bail!("download ownership namespace for {game_id} has no valid record")
|
||||
}
|
||||
LoadedOwnership::Foreign => eyre::bail!(
|
||||
"download ownership namespace for {game_id} contains a record bound to a different games directory"
|
||||
),
|
||||
LoadedOwnership::Invalid => {
|
||||
eyre::bail!("download ownership namespace for {game_id} contains an invalid record")
|
||||
}
|
||||
LoadedOwnership::Valid(record) if record.pending_files.is_some() => {
|
||||
eyre::bail!("download ownership recovery did not settle for absent game {game_id}")
|
||||
}
|
||||
LoadedOwnership::Valid(record)
|
||||
if record.committed_files.is_empty() && record.committed_content_id.is_none() =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
LoadedOwnership::Valid(_) => {
|
||||
let empty = DownloadOwnershipRecord::empty(game_id, games_folder_key);
|
||||
require_durable_record(
|
||||
publish_settled_record(&record_path, &tmp_path, &recovery_required_path, &empty)?,
|
||||
"absent downloaded-game ownership",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DownloadOwnershipTransaction {
|
||||
/// Recovers an earlier attempt and loads the last trustworthy ownership set.
|
||||
pub(super) async fn prepare(
|
||||
@@ -120,50 +1184,93 @@ impl DownloadOwnershipTransaction {
|
||||
manifest: &ValidatedDownloadManifest,
|
||||
game_root: &ConfinedGameRoot,
|
||||
) -> eyre::Result<Self> {
|
||||
let current_content_id = manifest.catalog_manifest().content_id();
|
||||
let games_folder_key = games_folder_key(manifest.games_folder());
|
||||
recover_incomplete_download_with_root(
|
||||
game_root,
|
||||
Some(game_root),
|
||||
state_dir,
|
||||
manifest.game_id(),
|
||||
&games_folder_key,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let record_path = download_ownership_path(state_dir, manifest.game_id());
|
||||
let tmp_path = download_ownership_tmp_path(state_dir, manifest.game_id());
|
||||
let (previous, needs_baseline) =
|
||||
match load_record(&record_path, manifest.game_id(), &games_folder_key).await {
|
||||
LoadedOwnership::Valid(record) => {
|
||||
(record.committed_files.into_iter().collect(), false)
|
||||
}
|
||||
LoadedOwnership::Missing | LoadedOwnership::Invalid => (BTreeSet::new(), true),
|
||||
};
|
||||
let current = manifest.owned_file_paths().into_iter().collect();
|
||||
validate_generation_aliases(&previous, ¤t)?;
|
||||
let untracked_targets = current
|
||||
.difference(&previous)
|
||||
.map(|path| ValidatedDownloadPath::from_ownership(path))
|
||||
.collect::<eyre::Result<Vec<_>>>()?;
|
||||
game_root
|
||||
.reject_existing_unowned_files(untracked_targets)
|
||||
.await?;
|
||||
if needs_baseline {
|
||||
let baseline = DownloadOwnershipRecord::empty(manifest.game_id(), &games_folder_key);
|
||||
require_durable_record(
|
||||
write_record(&record_path, &tmp_path, &baseline).await?,
|
||||
"download ownership baseline",
|
||||
)?;
|
||||
}
|
||||
scoped_ownership_fs(|| {
|
||||
let record_path =
|
||||
download_ownership_path(state_dir, manifest.game_id(), &games_folder_key);
|
||||
let tmp_path =
|
||||
download_ownership_tmp_path(state_dir, manifest.game_id(), &games_folder_key);
|
||||
let recovery_required_path = download_ownership_recovery_required_path(
|
||||
state_dir,
|
||||
manifest.game_id(),
|
||||
&games_folder_key,
|
||||
);
|
||||
let namespace_path =
|
||||
download_ownership_namespace_dir(state_dir, manifest.game_id(), &games_folder_key);
|
||||
let artifacts = inspect_ownership_namespace(&namespace_path)?;
|
||||
let (previous_content_id, previous, needs_baseline) =
|
||||
match load_record(&record_path, manifest.game_id(), &games_folder_key) {
|
||||
LoadedOwnership::Valid(record) => (
|
||||
record.committed_content_id,
|
||||
record.committed_files.into_iter().collect(),
|
||||
false,
|
||||
),
|
||||
LoadedOwnership::Missing if !artifacts.marker_exists => {
|
||||
(None, BTreeSet::new(), true)
|
||||
}
|
||||
LoadedOwnership::Missing => {
|
||||
eyre::bail!(
|
||||
"download ownership namespace for {} exists without a valid record",
|
||||
manifest.game_id()
|
||||
);
|
||||
}
|
||||
LoadedOwnership::Foreign => {
|
||||
eyre::bail!(
|
||||
"download ownership namespace for {} contains a record bound to a different games directory",
|
||||
manifest.game_id()
|
||||
);
|
||||
}
|
||||
LoadedOwnership::Invalid => {
|
||||
eyre::bail!(
|
||||
"download ownership namespace for {} contains an invalid record",
|
||||
manifest.game_id()
|
||||
);
|
||||
}
|
||||
};
|
||||
let current = manifest.owned_file_paths().into_iter().collect();
|
||||
validate_generation_aliases(&previous, ¤t)?;
|
||||
let untracked_targets = current
|
||||
.difference(&previous)
|
||||
.map(|path| ValidatedDownloadPath::from_ownership(path))
|
||||
.collect::<eyre::Result<Vec<_>>>()?;
|
||||
game_root.reject_existing_unowned_files(untracked_targets)?;
|
||||
if needs_baseline {
|
||||
let baseline =
|
||||
DownloadOwnershipRecord::empty(manifest.game_id(), &games_folder_key);
|
||||
require_durable_record(
|
||||
publish_settled_record(
|
||||
&record_path,
|
||||
&tmp_path,
|
||||
&recovery_required_path,
|
||||
&baseline,
|
||||
)?,
|
||||
"download ownership baseline",
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
record_path,
|
||||
tmp_path,
|
||||
game_id: manifest.game_id().to_owned(),
|
||||
games_folder_key,
|
||||
game_root: game_root.clone(),
|
||||
previous,
|
||||
current,
|
||||
Ok(Self {
|
||||
record_path,
|
||||
tmp_path,
|
||||
recovery_required_path,
|
||||
game_id: manifest.game_id().to_owned(),
|
||||
games_folder_key,
|
||||
game_root: game_root.clone(),
|
||||
previous_content_id,
|
||||
previous,
|
||||
current_content_id: Some(current_content_id),
|
||||
current,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn prepare_removal(
|
||||
@@ -172,118 +1279,125 @@ impl DownloadOwnershipTransaction {
|
||||
game_id: &str,
|
||||
) -> eyre::Result<Option<Self>> {
|
||||
validate_game_id(game_id)?;
|
||||
let games_folder_path = games_folder.to_path_buf();
|
||||
let games_folder =
|
||||
tokio::task::spawn_blocking(move || canonical_games_folder(&games_folder_path))
|
||||
.await??;
|
||||
let games_folder_key = games_folder_key(&games_folder);
|
||||
let record_path = download_ownership_path(state_dir, game_id);
|
||||
let tmp_path = download_ownership_tmp_path(state_dir, game_id);
|
||||
let Some(game_root) = ConfinedGameRoot::open_existing(&games_folder, game_id).await? else {
|
||||
let empty = DownloadOwnershipRecord::empty(game_id, &games_folder_key);
|
||||
require_durable_record(
|
||||
write_record(&record_path, &tmp_path, &empty).await?,
|
||||
"absent downloaded-game ownership",
|
||||
)?;
|
||||
let (game_root, games_folder_key) = scoped_ownership_fs(|| {
|
||||
let games_folder = canonical_games_folder(games_folder)?;
|
||||
let game_root = ConfinedGameRoot::open_existing(&games_folder, game_id)?;
|
||||
Ok::<_, eyre::Report>((game_root, games_folder_key(&games_folder)))
|
||||
})
|
||||
.await?;
|
||||
|
||||
recover_incomplete_download_with_root(
|
||||
game_root.as_ref(),
|
||||
state_dir,
|
||||
game_id,
|
||||
&games_folder_key,
|
||||
)
|
||||
.await?;
|
||||
let Some(game_root) = game_root else {
|
||||
scoped_ownership_fs(|| {
|
||||
clear_absent_download_ownership(state_dir, game_id, &games_folder_key)
|
||||
})
|
||||
.await?;
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
recover_incomplete_download_with_root(&game_root, state_dir, game_id, &games_folder_key)
|
||||
.await?;
|
||||
let record = match load_record(&record_path, game_id, &games_folder_key).await {
|
||||
LoadedOwnership::Valid(record) => record,
|
||||
LoadedOwnership::Missing => {
|
||||
eyre::bail!(
|
||||
"cannot safely remove downloaded files for {game_id}: the ownership record is missing; move or delete the legacy game folder manually"
|
||||
);
|
||||
}
|
||||
LoadedOwnership::Invalid => {
|
||||
eyre::bail!(
|
||||
"cannot safely remove downloaded files for {game_id}: the ownership record is invalid; move or delete the game folder manually"
|
||||
);
|
||||
}
|
||||
};
|
||||
if record.pending_files.is_some() {
|
||||
eyre::bail!("download ownership recovery did not settle for {game_id}");
|
||||
}
|
||||
if !game_root.root_regular_file_exists(VERSION_INI).await? {
|
||||
if record.committed_files.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
eyre::bail!("download sentinel is missing for {game_id}");
|
||||
}
|
||||
for (name, label) in [
|
||||
(LOCAL_DIR, "local install"),
|
||||
(INSTALLING_DIR, "install staging"),
|
||||
(BACKUP_DIR, "install backup"),
|
||||
] {
|
||||
if game_root.root_entry_exists(name).await? {
|
||||
eyre::bail!("refusing to remove downloaded files for {game_id} with {label}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(Self {
|
||||
record_path,
|
||||
tmp_path,
|
||||
game_id: game_id.to_owned(),
|
||||
games_folder_key,
|
||||
game_root,
|
||||
previous: record.committed_files.into_iter().collect(),
|
||||
current: BTreeSet::new(),
|
||||
}))
|
||||
let preparation =
|
||||
DownloadRemovalPreparation::new(game_root, state_dir, game_id, games_folder_key);
|
||||
scoped_ownership_fs(|| preparation.into_transaction_blocking()).await
|
||||
}
|
||||
|
||||
/// Publishes the proposed set after the old sentinel has been parked.
|
||||
pub(super) async fn journal_pending(&self) -> eyre::Result<OwnershipJournalPublication> {
|
||||
let record = DownloadOwnershipRecord {
|
||||
schema_version: OWNERSHIP_SCHEMA_VERSION,
|
||||
game_id: self.game_id.clone(),
|
||||
games_folder_key: self.games_folder_key.clone(),
|
||||
committed_files: self.previous.iter().cloned().collect(),
|
||||
pending_files: Some(self.current.iter().cloned().collect()),
|
||||
};
|
||||
write_record(&self.record_path, &self.tmp_path, &record).await
|
||||
scoped_ownership_fs(|| {
|
||||
let record = DownloadOwnershipRecord {
|
||||
schema_version: OWNERSHIP_SCHEMA_VERSION,
|
||||
game_id: self.game_id.clone(),
|
||||
games_folder_key: self.games_folder_key.clone(),
|
||||
committed_content_id: self.previous_content_id,
|
||||
committed_files: self.previous.iter().cloned().collect(),
|
||||
pending_content_id: self.current_content_id,
|
||||
pending_files: Some(self.current.iter().cloned().collect()),
|
||||
};
|
||||
match write_record(&self.record_path, &self.tmp_path, &record)? {
|
||||
OwnershipJournalPublication::NeedsRecovery(error) => {
|
||||
Ok(OwnershipJournalPublication::NeedsRecovery(error))
|
||||
}
|
||||
OwnershipJournalPublication::Durable => {
|
||||
match create_recovery_marker(&self.recovery_required_path) {
|
||||
Ok(()) => Ok(OwnershipJournalPublication::Durable),
|
||||
Err(error) => Ok(OwnershipJournalPublication::NeedsRecovery(
|
||||
error.wrap_err(
|
||||
"pending ownership is durable, but its recovery quarantine is uncertain",
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Removes only previously owned regular files absent from this manifest.
|
||||
pub(super) async fn remove_stale(&self) -> eyre::Result<()> {
|
||||
pub(super) fn remove_stale(&self) -> eyre::Result<()> {
|
||||
let stale = self
|
||||
.previous
|
||||
.difference(&self.current)
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
remove_owned_files(&self.game_root, &stale).await
|
||||
remove_owned_files(&self.game_root, &stale)
|
||||
}
|
||||
|
||||
/// Aborts a journaled attempt without touching paths outside either owned set.
|
||||
pub(super) async fn abort(&self) -> eyre::Result<()> {
|
||||
let removable = self
|
||||
.previous
|
||||
.union(&self.current)
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
remove_owned_files(&self.game_root, &removable).await?;
|
||||
discard_version_ini_transaction(&self.game_root).await?;
|
||||
let empty = DownloadOwnershipRecord::empty(&self.game_id, &self.games_folder_key);
|
||||
require_durable_record(
|
||||
write_record(&self.record_path, &self.tmp_path, &empty).await?,
|
||||
"aborted download ownership",
|
||||
)
|
||||
scoped_ownership_fs(|| {
|
||||
let removable = self
|
||||
.previous
|
||||
.union(&self.current)
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
remove_owned_files(&self.game_root, &removable)?;
|
||||
discard_version_ini_transaction(&self.game_root)?;
|
||||
let empty = DownloadOwnershipRecord::empty(&self.game_id, &self.games_folder_key);
|
||||
require_durable_record(
|
||||
publish_settled_record(
|
||||
&self.record_path,
|
||||
&self.tmp_path,
|
||||
&self.recovery_required_path,
|
||||
&empty,
|
||||
)?,
|
||||
"aborted download ownership",
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Finalizes ownership after the new `version.ini` commit point has landed.
|
||||
pub(super) async fn finalize(&self) -> eyre::Result<()> {
|
||||
let record = DownloadOwnershipRecord {
|
||||
schema_version: OWNERSHIP_SCHEMA_VERSION,
|
||||
game_id: self.game_id.clone(),
|
||||
games_folder_key: self.games_folder_key.clone(),
|
||||
committed_files: self.current.iter().cloned().collect(),
|
||||
pending_files: None,
|
||||
};
|
||||
require_durable_record(
|
||||
write_record(&self.record_path, &self.tmp_path, &record).await?,
|
||||
"finalized download ownership",
|
||||
)
|
||||
pub(super) async fn finalize(&self) -> eyre::Result<OwnershipJournalPublication> {
|
||||
self.finalize_with_parent_sync(sync_parent_dir).await
|
||||
}
|
||||
|
||||
async fn finalize_with_parent_sync(
|
||||
&self,
|
||||
sync_record_parent: impl FnOnce(&Path) -> std::io::Result<()>,
|
||||
) -> eyre::Result<OwnershipJournalPublication> {
|
||||
scoped_ownership_fs(|| {
|
||||
let record = DownloadOwnershipRecord {
|
||||
schema_version: OWNERSHIP_SCHEMA_VERSION,
|
||||
game_id: self.game_id.clone(),
|
||||
games_folder_key: self.games_folder_key.clone(),
|
||||
committed_content_id: self.current_content_id,
|
||||
committed_files: self.current.iter().cloned().collect(),
|
||||
pending_content_id: None,
|
||||
pending_files: None,
|
||||
};
|
||||
publish_settled_record_with_parent_sync(
|
||||
&self.record_path,
|
||||
&self.tmp_path,
|
||||
&self.recovery_required_path,
|
||||
&record,
|
||||
sync_record_parent,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,11 +1417,9 @@ pub(crate) async fn remove_downloaded_payload(
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Err(error) =
|
||||
super::version_ini::begin_version_ini_transaction(&transaction.game_root).await
|
||||
{
|
||||
if let Err(error) = super::version_ini::begin_version_ini_transaction(&transaction.game_root) {
|
||||
if let Err(restore_error) =
|
||||
restore_unjournaled_version_ini_transaction(&transaction.game_root).await
|
||||
restore_unjournaled_version_ini_transaction(&transaction.game_root)
|
||||
{
|
||||
return Err(error.wrap_err(format!(
|
||||
"sentinel parking failed and rollback also failed: {restore_error}"
|
||||
@@ -324,7 +1436,7 @@ pub(crate) async fn remove_downloaded_payload(
|
||||
}
|
||||
Err(error) => {
|
||||
if let Err(restore_error) =
|
||||
restore_unjournaled_version_ini_transaction(&transaction.game_root).await
|
||||
restore_unjournaled_version_ini_transaction(&transaction.game_root)
|
||||
{
|
||||
return Err(error.wrap_err(format!(
|
||||
"removal ownership journal failed and sentinel restore also failed: {restore_error}"
|
||||
@@ -336,9 +1448,12 @@ pub(crate) async fn remove_downloaded_payload(
|
||||
|
||||
// From the durable empty pending generation onward, recovery must roll the
|
||||
// removal forward. Never restore the sentinel on a later failure.
|
||||
transaction.remove_stale().await?;
|
||||
discard_version_ini_transaction(&transaction.game_root).await?;
|
||||
transaction.finalize().await
|
||||
transaction.remove_stale()?;
|
||||
discard_version_ini_transaction(&transaction.game_root)?;
|
||||
require_durable_record(
|
||||
transaction.finalize().await?,
|
||||
"finalized download removal ownership",
|
||||
)
|
||||
}
|
||||
|
||||
/// Recovers the ownership/version transaction for one inactive game root.
|
||||
@@ -353,87 +1468,150 @@ pub(crate) async fn recover_incomplete_download(
|
||||
game_root.display()
|
||||
);
|
||||
};
|
||||
let games_folder = match tokio::fs::canonicalize(games_folder).await {
|
||||
Ok(path) => path,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if game_root.file_name() != Some(std::ffi::OsStr::new(game_id)) {
|
||||
eyre::bail!(
|
||||
"game root is not the requested direct catalog child: {}",
|
||||
game_root.display()
|
||||
);
|
||||
}
|
||||
let Some(game_root) = ConfinedGameRoot::open_existing(&games_folder, game_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let key = games_folder_key(&games_folder);
|
||||
recover_incomplete_download_with_root(&game_root, state_dir, game_id, &key).await
|
||||
let (game_root, key) = scoped_ownership_fs(|| {
|
||||
let games_folder = canonical_games_folder(games_folder)?;
|
||||
let game_root = ConfinedGameRoot::open_existing(&games_folder, game_id)?;
|
||||
let key = games_folder_key(&games_folder);
|
||||
Ok::<_, eyre::Report>((game_root, key))
|
||||
})
|
||||
.await?;
|
||||
recover_incomplete_download_with_root(game_root.as_ref(), state_dir, game_id, &key).await
|
||||
}
|
||||
|
||||
async fn recover_incomplete_download_with_root(
|
||||
game_root: &ConfinedGameRoot,
|
||||
game_root: Option<&ConfinedGameRoot>,
|
||||
state_dir: &Path,
|
||||
game_id: &str,
|
||||
games_folder_key: &str,
|
||||
) -> eyre::Result<()> {
|
||||
let path = download_ownership_path(state_dir, game_id);
|
||||
let tmp_path = download_ownership_tmp_path(state_dir, game_id);
|
||||
scoped_ownership_fs(|| {
|
||||
migrate_current_legacy_ownership(state_dir, game_id)?;
|
||||
let path = download_ownership_path(state_dir, game_id, games_folder_key);
|
||||
let tmp_path = download_ownership_tmp_path(state_dir, game_id, games_folder_key);
|
||||
let recovery_required_path =
|
||||
download_ownership_recovery_required_path(state_dir, game_id, games_folder_key);
|
||||
let namespace_path =
|
||||
download_ownership_namespace_dir(state_dir, game_id, games_folder_key);
|
||||
let artifacts = inspect_ownership_namespace(&namespace_path)?;
|
||||
|
||||
match load_record(&path, game_id, games_folder_key).await {
|
||||
LoadedOwnership::Missing => {
|
||||
// Pre-journal versions used the same scratch name after payload
|
||||
// mutation. Without a new-format baseline, restoring it could
|
||||
// advertise partially overwritten bytes as a complete download.
|
||||
discard_version_ini_transaction(game_root).await?;
|
||||
}
|
||||
LoadedOwnership::Invalid => {
|
||||
// With no trustworthy journal, never make potentially partial bytes ready.
|
||||
discard_version_ini_transaction(game_root).await?;
|
||||
}
|
||||
LoadedOwnership::Valid(mut record) => {
|
||||
let Some(pending) = record.pending_files.clone() else {
|
||||
restore_unjournaled_version_ini_transaction(game_root).await?;
|
||||
sweep_tmp_file(&tmp_path).await;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let committed = record
|
||||
.committed_files
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let pending = pending.into_iter().collect::<BTreeSet<_>>();
|
||||
if game_root
|
||||
.root_regular_file_exists(crate::game_paths::VERSION_INI)
|
||||
.await?
|
||||
{
|
||||
let stale = committed
|
||||
.difference(&pending)
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
remove_owned_files(game_root, &stale).await?;
|
||||
finish_recovered_version_ini_transaction(game_root).await?;
|
||||
record.committed_files = pending.into_iter().collect();
|
||||
record.pending_files = None;
|
||||
require_durable_record(
|
||||
write_record(&path, &tmp_path, &record).await?,
|
||||
"recovered committed download ownership",
|
||||
)?;
|
||||
} else {
|
||||
let removable = committed.union(&pending).cloned().collect::<BTreeSet<_>>();
|
||||
remove_owned_files(game_root, &removable).await?;
|
||||
discard_version_ini_transaction(game_root).await?;
|
||||
let empty = DownloadOwnershipRecord::empty(game_id, games_folder_key);
|
||||
require_durable_record(
|
||||
write_record(&path, &tmp_path, &empty).await?,
|
||||
"recovered aborted download ownership",
|
||||
)?;
|
||||
match load_record(&path, game_id, games_folder_key) {
|
||||
LoadedOwnership::Missing if !artifacts.marker_exists => {
|
||||
// Pre-journal versions used the same scratch name after payload
|
||||
// mutation. Without a new-format baseline, restoring it could
|
||||
// advertise partially overwritten bytes as a complete download.
|
||||
if let Some(game_root) = game_root {
|
||||
discard_version_ini_transaction(game_root)?;
|
||||
}
|
||||
}
|
||||
LoadedOwnership::Missing => eyre::bail!(
|
||||
"download ownership namespace for {game_id} exists without a valid record"
|
||||
),
|
||||
LoadedOwnership::Foreign => {
|
||||
eyre::bail!(
|
||||
"download ownership namespace for {game_id} contains a record bound to a different games directory"
|
||||
);
|
||||
}
|
||||
LoadedOwnership::Invalid => {
|
||||
eyre::bail!(
|
||||
"download ownership namespace for {game_id} contains an invalid record"
|
||||
);
|
||||
}
|
||||
LoadedOwnership::Valid(record) => recover_valid_ownership(
|
||||
game_root,
|
||||
game_id,
|
||||
games_folder_key,
|
||||
&path,
|
||||
&tmp_path,
|
||||
&recovery_required_path,
|
||||
artifacts.marker_exists,
|
||||
record,
|
||||
)?,
|
||||
}
|
||||
sweep_tmp_file(&tmp_path);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn recover_valid_ownership(
|
||||
game_root: Option<&ConfinedGameRoot>,
|
||||
game_id: &str,
|
||||
games_folder_key: &str,
|
||||
path: &Path,
|
||||
tmp_path: &Path,
|
||||
recovery_required_path: &Path,
|
||||
recovery_required: bool,
|
||||
mut record: DownloadOwnershipRecord,
|
||||
) -> eyre::Result<()> {
|
||||
let Some(pending) = record.pending_files.clone() else {
|
||||
if recovery_required {
|
||||
// Finalization may have made the settled record visible before
|
||||
// losing certainty about its rename. Re-publish the same record
|
||||
// durably before clearing the quarantine.
|
||||
if let Some(game_root) = game_root {
|
||||
discard_version_ini_transaction(game_root)?;
|
||||
}
|
||||
require_durable_record(
|
||||
publish_settled_record(path, tmp_path, recovery_required_path, &record)?,
|
||||
"recovered settled download ownership",
|
||||
)?;
|
||||
} else if let Some(game_root) = game_root {
|
||||
restore_unjournaled_version_ini_transaction(game_root)?;
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let committed = record
|
||||
.committed_files
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let pending = pending.into_iter().collect::<BTreeSet<_>>();
|
||||
let Some(game_root) = game_root else {
|
||||
let empty = DownloadOwnershipRecord::empty(game_id, games_folder_key);
|
||||
return require_durable_record(
|
||||
publish_settled_record(path, tmp_path, recovery_required_path, &empty)?,
|
||||
"recovered absent-root download ownership",
|
||||
);
|
||||
};
|
||||
|
||||
if game_root.root_regular_file_exists(crate::game_paths::VERSION_INI)? {
|
||||
let pending_content_id = record.pending_content_id.ok_or_else(|| {
|
||||
eyre::eyre!(
|
||||
"download removal intent for {game_id} unexpectedly has a committed sentinel"
|
||||
)
|
||||
})?;
|
||||
let stale = committed
|
||||
.difference(&pending)
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
remove_owned_files(game_root, &stale)?;
|
||||
finish_recovered_version_ini_transaction(game_root)?;
|
||||
record.committed_content_id = Some(pending_content_id);
|
||||
record.committed_files = pending.into_iter().collect();
|
||||
record.pending_content_id = None;
|
||||
record.pending_files = None;
|
||||
require_durable_record(
|
||||
publish_settled_record(path, tmp_path, recovery_required_path, &record)?,
|
||||
"recovered committed download ownership",
|
||||
)
|
||||
} else {
|
||||
let removable = committed.union(&pending).cloned().collect::<BTreeSet<_>>();
|
||||
remove_owned_files(game_root, &removable)?;
|
||||
discard_version_ini_transaction(game_root)?;
|
||||
let empty = DownloadOwnershipRecord::empty(game_id, games_folder_key);
|
||||
require_durable_record(
|
||||
publish_settled_record(path, tmp_path, recovery_required_path, &empty)?,
|
||||
"recovered aborted download ownership",
|
||||
)
|
||||
}
|
||||
sweep_tmp_file(&tmp_path).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_file_set(paths: &[String]) -> eyre::Result<()> {
|
||||
@@ -480,13 +1658,95 @@ fn validate_generation_aliases(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_record(
|
||||
fn open_ambient_directory_nofollow(path: &Path) -> std::io::Result<std::fs::File> {
|
||||
let directory = cap_fs::open_ambient(path, &directory_options(), ambient_authority())?;
|
||||
validate_directory_handle(&directory, path)?;
|
||||
Ok(directory)
|
||||
}
|
||||
|
||||
fn open_directory_at(parent: &std::fs::File, path: &Path) -> std::io::Result<std::fs::File> {
|
||||
let directory = cap_fs::open(parent, path, &directory_options())?;
|
||||
validate_directory_handle(&directory, path)?;
|
||||
Ok(directory)
|
||||
}
|
||||
|
||||
fn open_regular_file_at(parent: &std::fs::File, path: &Path) -> std::io::Result<std::fs::File> {
|
||||
let file = cap_fs::open(parent, path, ®ular_file_options())?;
|
||||
validate_regular_file_handle(&file, path)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn open_ambient_regular_file_nofollow(path: &Path) -> std::io::Result<std::fs::File> {
|
||||
let file = cap_fs::open_ambient(path, ®ular_file_options(), ambient_authority())?;
|
||||
validate_regular_file_handle(&file, path)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn directory_options() -> CapOpenOptions {
|
||||
let mut options = CapOpenOptions::new();
|
||||
options.read(true);
|
||||
options
|
||||
.maybe_dir(true)
|
||||
.follow(FollowSymlinks::No)
|
||||
.nonblock(true);
|
||||
options
|
||||
}
|
||||
|
||||
fn regular_file_options() -> CapOpenOptions {
|
||||
let mut options = CapOpenOptions::new();
|
||||
options.read(true);
|
||||
options.follow(FollowSymlinks::No).nonblock(true);
|
||||
options
|
||||
}
|
||||
|
||||
fn validate_directory_handle(file: &std::fs::File, display: &Path) -> std::io::Result<()> {
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_dir() || is_windows_reparse(&metadata) {
|
||||
return Err(std::io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"download ownership state is not a non-reparse directory: {}",
|
||||
display.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_regular_file_handle(file: &std::fs::File, display: &Path) -> std::io::Result<()> {
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_file() || is_windows_reparse(&metadata) {
|
||||
return Err(std::io::Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"download ownership state is not a regular non-reparse file: {}",
|
||||
display.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_windows_reparse(metadata: &std::fs::Metadata) -> bool {
|
||||
use std::os::windows::fs::MetadataExt as _;
|
||||
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
||||
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
const fn is_windows_reparse(_metadata: &std::fs::Metadata) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn load_record(
|
||||
path: &Path,
|
||||
expected_game_id: &str,
|
||||
expected_games_folder_key: &str,
|
||||
) -> LoadedOwnership {
|
||||
let metadata = match tokio::fs::metadata(path).await {
|
||||
Ok(metadata) => metadata,
|
||||
let file = match open_ambient_regular_file_nofollow(path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => return LoadedOwnership::Missing,
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
@@ -496,15 +1756,7 @@ async fn load_record(
|
||||
return LoadedOwnership::Invalid;
|
||||
}
|
||||
};
|
||||
if !metadata.is_file() || metadata.len() > MAX_OWNERSHIP_RECORD_BYTES {
|
||||
log::warn!(
|
||||
"Ignoring invalid download ownership file {}",
|
||||
path.display()
|
||||
);
|
||||
return LoadedOwnership::Invalid;
|
||||
}
|
||||
|
||||
let bytes = match tokio::fs::read(path).await {
|
||||
let bytes = match read_bounded_file(file, MAX_OWNERSHIP_RECORD_BYTES) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
@@ -514,11 +1766,23 @@ async fn load_record(
|
||||
return LoadedOwnership::Invalid;
|
||||
}
|
||||
};
|
||||
match serde_json::from_slice::<DownloadOwnershipRecord>(&bytes)
|
||||
.map_err(eyre::Report::from)
|
||||
.and_then(|record| record.validate(expected_game_id, expected_games_folder_key))
|
||||
{
|
||||
Ok(record) => LoadedOwnership::Valid(record),
|
||||
match serde_json::from_slice::<DownloadOwnershipRecord>(&bytes).map_err(eyre::Report::from) {
|
||||
Ok(record) => {
|
||||
let record_games_folder_key = record.games_folder_key.clone();
|
||||
match record.validate(expected_game_id, &record_games_folder_key) {
|
||||
Ok(_) if record_games_folder_key != expected_games_folder_key => {
|
||||
LoadedOwnership::Foreign
|
||||
}
|
||||
Ok(record) => LoadedOwnership::Valid(record),
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"Ignoring invalid download ownership {}: {error}",
|
||||
path.display()
|
||||
);
|
||||
LoadedOwnership::Invalid
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"Ignoring invalid download ownership {}: {error}",
|
||||
@@ -529,15 +1793,124 @@ async fn load_record(
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_record(
|
||||
fn create_recovery_marker(path: &Path) -> eyre::Result<()> {
|
||||
create_recovery_marker_with_parent_sync(path, sync_parent_dir)
|
||||
}
|
||||
|
||||
fn create_recovery_marker_with_parent_sync(
|
||||
path: &Path,
|
||||
sync_parent: impl FnOnce(&Path) -> std::io::Result<()>,
|
||||
) -> eyre::Result<()> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| eyre::eyre!("download ownership recovery marker has no parent"))?;
|
||||
create_state_parent_durably(parent)?;
|
||||
|
||||
let mut options = CapOpenOptions::new();
|
||||
options.read(true).write(true).create(true);
|
||||
options.follow(FollowSymlinks::No).nonblock(true);
|
||||
let marker = cap_fs::open_ambient(path, &options, ambient_authority()).wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to open download ownership recovery marker without following links at {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
validate_recovery_marker_handle(&marker, path)?;
|
||||
|
||||
let mut marker = marker;
|
||||
marker.set_len(0)?;
|
||||
marker.write_all(b"recovery required\n")?;
|
||||
marker.sync_all()?;
|
||||
drop(marker);
|
||||
sync_parent(path).wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to make download ownership recovery marker durable at {}",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_recovery_marker_handle(marker: &std::fs::File, path: &Path) -> std::io::Result<()> {
|
||||
validate_regular_file_handle(marker, path)
|
||||
}
|
||||
|
||||
fn clear_recovery_marker(path: &Path) -> eyre::Result<()> {
|
||||
clear_recovery_marker_with_parent_sync(path, sync_parent_dir)
|
||||
}
|
||||
|
||||
fn clear_recovery_marker_with_parent_sync(
|
||||
path: &Path,
|
||||
sync_parent: impl FnOnce(&Path) -> std::io::Result<()>,
|
||||
) -> eyre::Result<()> {
|
||||
remove_file_if_exists(path).wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to clear download ownership recovery marker {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// The settled record was already made durable before the marker was
|
||||
// removed. A directory-sync failure here can only resurrect the marker
|
||||
// after a crash, which is a conservative false positive. The marker is
|
||||
// visibly absent now, so returning recovery-required would itself permit a
|
||||
// contradictory readiness observation.
|
||||
if let Err(error) = sync_parent(path) {
|
||||
log::warn!(
|
||||
"Cleared download ownership recovery marker {}, but its removal may not survive a power loss: {error}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn publish_settled_record(
|
||||
path: &Path,
|
||||
tmp_path: &Path,
|
||||
recovery_required_path: &Path,
|
||||
record: &DownloadOwnershipRecord,
|
||||
) -> eyre::Result<OwnershipJournalPublication> {
|
||||
publish_settled_record_with_parent_sync(
|
||||
path,
|
||||
tmp_path,
|
||||
recovery_required_path,
|
||||
record,
|
||||
sync_parent_dir,
|
||||
)
|
||||
}
|
||||
|
||||
fn publish_settled_record_with_parent_sync(
|
||||
path: &Path,
|
||||
tmp_path: &Path,
|
||||
recovery_required_path: &Path,
|
||||
record: &DownloadOwnershipRecord,
|
||||
sync_record_parent: impl FnOnce(&Path) -> std::io::Result<()>,
|
||||
) -> eyre::Result<OwnershipJournalPublication> {
|
||||
debug_assert!(record.pending_files.is_none());
|
||||
debug_assert!(record.pending_content_id.is_none());
|
||||
create_recovery_marker(recovery_required_path)?;
|
||||
|
||||
match write_record_with_parent_sync(path, tmp_path, record, sync_record_parent)? {
|
||||
OwnershipJournalPublication::NeedsRecovery(error) => {
|
||||
Ok(OwnershipJournalPublication::NeedsRecovery(error))
|
||||
}
|
||||
OwnershipJournalPublication::Durable => {
|
||||
match clear_recovery_marker(recovery_required_path) {
|
||||
Ok(()) => Ok(OwnershipJournalPublication::Durable),
|
||||
Err(error) => Ok(OwnershipJournalPublication::NeedsRecovery(error)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_record(
|
||||
path: &Path,
|
||||
tmp_path: &Path,
|
||||
record: &DownloadOwnershipRecord,
|
||||
) -> 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)
|
||||
}
|
||||
|
||||
async fn write_record_with_parent_sync(
|
||||
fn write_record_with_parent_sync(
|
||||
path: &Path,
|
||||
tmp_path: &Path,
|
||||
record: &DownloadOwnershipRecord,
|
||||
@@ -546,19 +1919,30 @@ async fn write_record_with_parent_sync(
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| eyre::eyre!("download ownership path has no parent"))?;
|
||||
create_state_parent_durably(parent).await?;
|
||||
create_state_parent_durably(parent)?;
|
||||
let bytes = serde_json::to_vec_pretty(record)?;
|
||||
if u64::try_from(bytes.len())? > MAX_OWNERSHIP_RECORD_BYTES {
|
||||
eyre::bail!("download ownership record exceeds its size limit");
|
||||
}
|
||||
|
||||
let mut file = tokio::fs::File::create(tmp_path).await?;
|
||||
file.write_all(&bytes).await?;
|
||||
file.sync_all().await?;
|
||||
let mut options = CapOpenOptions::new();
|
||||
options.read(true).write(true).create(true);
|
||||
options.follow(FollowSymlinks::No).nonblock(true);
|
||||
let mut file =
|
||||
cap_fs::open_ambient(tmp_path, &options, ambient_authority()).wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to open download ownership temporary file without following links at {}",
|
||||
tmp_path.display()
|
||||
)
|
||||
})?;
|
||||
validate_regular_file_handle(&file, tmp_path)?;
|
||||
file.set_len(0)?;
|
||||
file.write_all(&bytes)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
tokio::fs::rename(tmp_path, path).await?;
|
||||
std::fs::rename(tmp_path, path)?;
|
||||
if let Err(error) = sync_parent(path) {
|
||||
return Ok(OwnershipJournalPublication::NeedsRecovery(error));
|
||||
return Ok(OwnershipJournalPublication::NeedsRecovery(error.into()));
|
||||
}
|
||||
Ok(OwnershipJournalPublication::Durable)
|
||||
}
|
||||
@@ -575,24 +1959,21 @@ fn require_durable_record(
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_owned_files(
|
||||
game_root: &ConfinedGameRoot,
|
||||
paths: &BTreeSet<String>,
|
||||
) -> eyre::Result<()> {
|
||||
fn remove_owned_files(game_root: &ConfinedGameRoot, paths: &BTreeSet<String>) -> eyre::Result<()> {
|
||||
let paths = paths
|
||||
.iter()
|
||||
.map(|path| ValidatedDownloadPath::from_ownership(path))
|
||||
.collect::<eyre::Result<Vec<_>>>()?;
|
||||
game_root.remove_owned_regular_files(paths).await
|
||||
game_root.remove_owned_regular_files(paths)
|
||||
}
|
||||
|
||||
async fn create_state_parent_durably(path: &Path) -> eyre::Result<()> {
|
||||
fn create_state_parent_durably(path: &Path) -> eyre::Result<()> {
|
||||
let mut missing = Vec::new();
|
||||
let mut cursor = Some(path);
|
||||
while let Some(candidate) = cursor {
|
||||
match tokio::fs::symlink_metadata(candidate).await {
|
||||
match std::fs::symlink_metadata(candidate) {
|
||||
Ok(metadata) => {
|
||||
if !metadata.is_dir() {
|
||||
if !metadata.is_dir() || is_windows_reparse(&metadata) {
|
||||
eyre::bail!(
|
||||
"download ownership parent is not a directory: {}",
|
||||
candidate.display()
|
||||
@@ -608,23 +1989,29 @@ async fn create_state_parent_durably(path: &Path) -> eyre::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
tokio::fs::create_dir_all(path).await?;
|
||||
std::fs::create_dir_all(path)?;
|
||||
open_ambient_directory_nofollow(path).wrap_err_with(|| {
|
||||
format!(
|
||||
"download ownership parent is not a safe directory: {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
for created in missing.iter().rev() {
|
||||
sync_parent_dir(created)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_file_if_exists(path: &Path) -> eyre::Result<()> {
|
||||
match tokio::fs::remove_file(path).await {
|
||||
fn remove_file_if_exists(path: &Path) -> eyre::Result<()> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn sweep_tmp_file(path: &Path) {
|
||||
if let Err(error) = remove_file_if_exists(path).await {
|
||||
fn sweep_tmp_file(path: &Path) {
|
||||
if let Err(error) = remove_file_if_exists(path) {
|
||||
log::warn!(
|
||||
"Failed to sweep ownership scratch {}: {error}",
|
||||
path.display()
|
||||
@@ -639,58 +2026,82 @@ pub(crate) async fn seed_download_ownership_for_test(
|
||||
game_id: &str,
|
||||
committed_files: &[&str],
|
||||
) {
|
||||
let games_folder = canonical_games_folder(games_folder).expect("games folder should resolve");
|
||||
let record = DownloadOwnershipRecord {
|
||||
schema_version: OWNERSHIP_SCHEMA_VERSION,
|
||||
game_id: game_id.to_owned(),
|
||||
games_folder_key: games_folder_key(&games_folder),
|
||||
committed_files: committed_files.iter().map(ToString::to_string).collect(),
|
||||
pending_files: None,
|
||||
};
|
||||
require_durable_record(
|
||||
write_record(
|
||||
&download_ownership_path(state_dir, game_id),
|
||||
&download_ownership_tmp_path(state_dir, game_id),
|
||||
&record,
|
||||
)
|
||||
.await
|
||||
.expect("test ownership should publish"),
|
||||
"test ownership",
|
||||
seed_download_ownership_generation_for_test(
|
||||
state_dir,
|
||||
games_folder,
|
||||
game_id,
|
||||
committed_files,
|
||||
None,
|
||||
)
|
||||
.expect("test ownership should be durable");
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn games_folder_key(path: &Path) -> String {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
format!("unix:{}", hex_encode(path.as_os_str().as_bytes()))
|
||||
#[cfg(test)]
|
||||
const fn test_content_id() -> ContentId {
|
||||
ContentId::from_bytes([0x42; 32])
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn games_folder_key(path: &Path) -> String {
|
||||
use std::{fmt::Write as _, os::windows::ffi::OsStrExt};
|
||||
|
||||
let mut encoded = String::from("windows:");
|
||||
for unit in path.as_os_str().encode_wide() {
|
||||
let _ = write!(encoded, "{unit:04x}");
|
||||
}
|
||||
encoded
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn seed_pending_download_ownership_for_test(
|
||||
state_dir: &Path,
|
||||
games_folder: &Path,
|
||||
game_id: &str,
|
||||
committed_files: &[&str],
|
||||
pending_files: &[&str],
|
||||
) {
|
||||
seed_download_ownership_generation_for_test(
|
||||
state_dir,
|
||||
games_folder,
|
||||
game_id,
|
||||
committed_files,
|
||||
Some(pending_files),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn games_folder_key(path: &Path) -> String {
|
||||
format!("native:{}", hex_encode(path.as_os_str().as_encoded_bytes()))
|
||||
}
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
let mut encoded = String::with_capacity(bytes.len() * 2);
|
||||
for byte in bytes {
|
||||
let _ = write!(encoded, "{byte:02x}");
|
||||
}
|
||||
encoded
|
||||
#[cfg(test)]
|
||||
async fn seed_download_ownership_generation_for_test(
|
||||
state_dir: &Path,
|
||||
games_folder: &Path,
|
||||
game_id: &str,
|
||||
committed_files: &[&str],
|
||||
pending_files: Option<&[&str]>,
|
||||
) {
|
||||
scoped_ownership_fs(|| {
|
||||
let games_folder =
|
||||
canonical_games_folder(games_folder).expect("games folder should resolve");
|
||||
let is_pending = pending_files.is_some();
|
||||
let record = DownloadOwnershipRecord {
|
||||
schema_version: OWNERSHIP_SCHEMA_VERSION,
|
||||
game_id: game_id.to_owned(),
|
||||
games_folder_key: games_folder_key(&games_folder),
|
||||
committed_content_id: (!committed_files.is_empty()).then_some(test_content_id()),
|
||||
committed_files: committed_files.iter().map(ToString::to_string).collect(),
|
||||
pending_content_id: pending_files
|
||||
.filter(|paths| !paths.is_empty())
|
||||
.map(|_| test_content_id()),
|
||||
pending_files: pending_files
|
||||
.map(|paths| paths.iter().map(ToString::to_string).collect()),
|
||||
};
|
||||
let games_folder_key = games_folder_key(&games_folder);
|
||||
let record_path = download_ownership_path(state_dir, game_id, &games_folder_key);
|
||||
let tmp_path = download_ownership_tmp_path(state_dir, game_id, &games_folder_key);
|
||||
let recovery_required_path =
|
||||
download_ownership_recovery_required_path(state_dir, game_id, &games_folder_key);
|
||||
let publication = if is_pending {
|
||||
write_record(&record_path, &tmp_path, &record).expect("test ownership should publish")
|
||||
} else {
|
||||
publish_settled_record(&record_path, &tmp_path, &recovery_required_path, &record)
|
||||
.expect("test ownership should publish")
|
||||
};
|
||||
require_durable_record(publication, "test ownership")
|
||||
.expect("test ownership should be durable");
|
||||
if is_pending {
|
||||
create_recovery_marker(&recovery_required_path)
|
||||
.expect("test recovery marker should be durable");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -708,7 +2119,14 @@ const fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use lanspread_db::db::{GameCatalog, GameFileDescription};
|
||||
use std::sync::Arc;
|
||||
|
||||
use lanspread_db::content_manifest::{
|
||||
Blake3Digest,
|
||||
CatalogContentManifest,
|
||||
CatalogContentManifestBody,
|
||||
CatalogFileEntry,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::{
|
||||
@@ -717,25 +2135,46 @@ mod tests {
|
||||
};
|
||||
|
||||
fn manifest(games_folder: &Path, files: &[&str]) -> ValidatedDownloadManifest {
|
||||
let mut descriptions = vec![GameFileDescription {
|
||||
game_id: "game".to_owned(),
|
||||
relative_path: "game/version.ini".to_owned(),
|
||||
is_dir: false,
|
||||
size: 8,
|
||||
}];
|
||||
descriptions.extend(files.iter().map(|path| GameFileDescription {
|
||||
game_id: "game".to_owned(),
|
||||
relative_path: format!("game/{path}"),
|
||||
is_dir: false,
|
||||
size: 4,
|
||||
manifest_with_version(games_folder, files, "20250101")
|
||||
}
|
||||
|
||||
fn manifest_with_version(
|
||||
games_folder: &Path,
|
||||
files: &[&str],
|
||||
game_version: &str,
|
||||
) -> ValidatedDownloadManifest {
|
||||
let version_digest = Blake3Digest::hash(game_version.as_bytes());
|
||||
let file_digest = Blake3Digest::hash(b"data");
|
||||
let mut entries = vec![
|
||||
CatalogFileEntry::file(
|
||||
VERSION_INI,
|
||||
u64::try_from(game_version.len()).expect("version length should fit"),
|
||||
version_digest,
|
||||
vec![version_digest],
|
||||
)
|
||||
.expect("version entry should validate"),
|
||||
];
|
||||
let mut directories = BTreeSet::new();
|
||||
for path in files {
|
||||
let components = path.split('/').collect::<Vec<_>>();
|
||||
for component_count in 1..components.len() {
|
||||
directories.insert(components[..component_count].join("/"));
|
||||
}
|
||||
}
|
||||
entries.extend(directories.into_iter().map(|path| {
|
||||
CatalogFileEntry::directory(path).expect("directory entry should validate")
|
||||
}));
|
||||
ValidatedDownloadManifest::from_protocol_v7(
|
||||
games_folder,
|
||||
"game",
|
||||
descriptions,
|
||||
&GameCatalog::from_ids(["game".to_owned()]),
|
||||
)
|
||||
.expect("manifest should validate")
|
||||
entries.extend(files.iter().map(|path| {
|
||||
CatalogFileEntry::file(*path, 4, file_digest, vec![file_digest])
|
||||
.expect("file entry should validate")
|
||||
}));
|
||||
entries.sort_by(|left, right| left.canonical_path().cmp(right.canonical_path()));
|
||||
let body = CatalogContentManifestBody::new("game", game_version, entries, Vec::new())
|
||||
.expect("manifest body should validate");
|
||||
let catalog =
|
||||
Arc::new(CatalogContentManifest::seal(body).expect("catalog manifest should validate"));
|
||||
ValidatedDownloadManifest::from_catalog(games_folder, catalog)
|
||||
.expect("download manifest should validate")
|
||||
}
|
||||
|
||||
fn write_file(path: &Path, bytes: &[u8]) {
|
||||
@@ -745,87 +2184,691 @@ mod tests {
|
||||
std::fs::write(path, bytes).expect("file should be written");
|
||||
}
|
||||
|
||||
fn ownership_record_path(state_dir: &Path, games_folder: &Path) -> PathBuf {
|
||||
download_ownership_path(state_dir, "game", &games_folder_key(games_folder))
|
||||
}
|
||||
|
||||
fn ownership_tmp_path(state_dir: &Path, games_folder: &Path) -> PathBuf {
|
||||
download_ownership_tmp_path(state_dir, "game", &games_folder_key(games_folder))
|
||||
}
|
||||
|
||||
fn ownership_marker_path(state_dir: &Path, games_folder: &Path) -> PathBuf {
|
||||
download_ownership_recovery_required_path(
|
||||
state_dir,
|
||||
"game",
|
||||
&games_folder_key(games_folder),
|
||||
)
|
||||
}
|
||||
|
||||
async fn seed_record(
|
||||
state_dir: &Path,
|
||||
games_folder: &Path,
|
||||
committed: &[&str],
|
||||
pending: Option<&[&str]>,
|
||||
) {
|
||||
seed_record_with_content_ids(
|
||||
state_dir,
|
||||
games_folder,
|
||||
committed,
|
||||
(!committed.is_empty()).then_some(test_content_id()),
|
||||
pending,
|
||||
pending
|
||||
.filter(|paths| !paths.is_empty())
|
||||
.map(|_| test_content_id()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn seed_record_with_content_ids(
|
||||
state_dir: &Path,
|
||||
games_folder: &Path,
|
||||
committed: &[&str],
|
||||
committed_content_id: Option<ContentId>,
|
||||
pending: Option<&[&str]>,
|
||||
pending_content_id: Option<ContentId>,
|
||||
) {
|
||||
scoped_ownership_fs(|| {
|
||||
let games_folder_key = games_folder_key(games_folder);
|
||||
let record = DownloadOwnershipRecord {
|
||||
schema_version: OWNERSHIP_SCHEMA_VERSION,
|
||||
game_id: "game".to_owned(),
|
||||
games_folder_key: games_folder_key.clone(),
|
||||
committed_content_id,
|
||||
committed_files: committed.iter().map(ToString::to_string).collect(),
|
||||
pending_content_id,
|
||||
pending_files: pending.map(|paths| paths.iter().map(ToString::to_string).collect()),
|
||||
};
|
||||
require_durable_record(
|
||||
write_record(
|
||||
&download_ownership_path(state_dir, "game", &games_folder_key),
|
||||
&download_ownership_tmp_path(state_dir, "game", &games_folder_key),
|
||||
&record,
|
||||
)
|
||||
.expect("record should be published"),
|
||||
"test ownership",
|
||||
)
|
||||
.expect("record should be durable");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
fn seed_legacy_record(
|
||||
state_dir: &Path,
|
||||
games_folder: &Path,
|
||||
committed: &[&str],
|
||||
pending: Option<&[&str]>,
|
||||
marker: bool,
|
||||
) {
|
||||
let record = DownloadOwnershipRecord {
|
||||
schema_version: OWNERSHIP_SCHEMA_VERSION,
|
||||
game_id: "game".to_owned(),
|
||||
games_folder_key: games_folder_key(games_folder),
|
||||
committed_content_id: (!committed.is_empty()).then_some(test_content_id()),
|
||||
committed_files: committed.iter().map(ToString::to_string).collect(),
|
||||
pending_content_id: pending
|
||||
.filter(|paths| !paths.is_empty())
|
||||
.map(|_| test_content_id()),
|
||||
pending_files: pending.map(|paths| paths.iter().map(ToString::to_string).collect()),
|
||||
};
|
||||
require_durable_record(
|
||||
write_record(
|
||||
&download_ownership_path(state_dir, "game"),
|
||||
&download_ownership_tmp_path(state_dir, "game"),
|
||||
&record,
|
||||
)
|
||||
.await
|
||||
.expect("record should be published"),
|
||||
"test ownership",
|
||||
)
|
||||
.expect("record should be durable");
|
||||
}
|
||||
|
||||
async fn read_valid_record(state_dir: &Path, games_folder: &Path) -> DownloadOwnershipRecord {
|
||||
match load_record(
|
||||
&download_ownership_path(state_dir, "game"),
|
||||
"game",
|
||||
&games_folder_key(games_folder),
|
||||
)
|
||||
.await
|
||||
{
|
||||
LoadedOwnership::Valid(record) => record,
|
||||
LoadedOwnership::Missing => panic!("record should exist"),
|
||||
LoadedOwnership::Invalid => panic!("record should be valid"),
|
||||
write_file(
|
||||
&legacy_download_ownership_path(state_dir, "game"),
|
||||
&serde_json::to_vec_pretty(&record).expect("legacy record should encode"),
|
||||
);
|
||||
if marker {
|
||||
write_file(
|
||||
&legacy_download_ownership_recovery_required_path(state_dir, "game"),
|
||||
RECOVERY_MARKER_BYTES,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn confined_root(manifest: &ValidatedDownloadManifest) -> ConfinedGameRoot {
|
||||
async fn read_valid_record(state_dir: &Path, games_folder: &Path) -> DownloadOwnershipRecord {
|
||||
scoped_ownership_fs(|| {
|
||||
let games_folder_key = games_folder_key(games_folder);
|
||||
match load_record(
|
||||
&download_ownership_path(state_dir, "game", &games_folder_key),
|
||||
"game",
|
||||
&games_folder_key,
|
||||
) {
|
||||
LoadedOwnership::Valid(record) => record,
|
||||
LoadedOwnership::Missing => panic!("record should exist"),
|
||||
LoadedOwnership::Foreign => {
|
||||
panic!("record should belong to this games directory")
|
||||
}
|
||||
LoadedOwnership::Invalid => panic!("record should be valid"),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn confined_root(manifest: &ValidatedDownloadManifest) -> ConfinedGameRoot {
|
||||
ConfinedGameRoot::open_or_create(manifest.games_folder(), manifest.game_id())
|
||||
.await
|
||||
.expect("confined game root should open")
|
||||
}
|
||||
|
||||
async fn readiness(games_folder: &Path, state_dir: &Path) -> DownloadOwnershipReadiness {
|
||||
download_ownership_readiness(games_folder, state_dir, "game").await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_classifies_every_ownership_state_and_binding() {
|
||||
let games = TempDir::new("lanspread-ownership-readiness-games");
|
||||
let foreign_games = TempDir::new("lanspread-ownership-readiness-foreign-games");
|
||||
let state = TempDir::new("lanspread-ownership-readiness-state");
|
||||
let record_path = ownership_record_path(state.path(), games.path());
|
||||
let marker_path = ownership_marker_path(state.path(), games.path());
|
||||
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::Untracked
|
||||
);
|
||||
|
||||
// A marker inside the selected root namespace is enough to fail
|
||||
// closed: it may be the only durable evidence of an interrupted
|
||||
// first publication.
|
||||
create_recovery_marker(&marker_path).expect("marker should publish");
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::RecoveryRequired
|
||||
);
|
||||
remove_file_if_exists(&marker_path).expect("marker should clear");
|
||||
|
||||
seed_record(state.path(), games.path(), &["archive.eti"], None).await;
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::Settled
|
||||
);
|
||||
|
||||
create_recovery_marker(&marker_path).expect("marker should publish");
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::RecoveryRequired
|
||||
);
|
||||
remove_file_if_exists(&marker_path).expect("marker should clear");
|
||||
|
||||
seed_record(
|
||||
state.path(),
|
||||
games.path(),
|
||||
&["archive.eti"],
|
||||
Some(&["archive.eti"]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::RecoveryRequired
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(record_path.parent().expect("record should have a parent"))
|
||||
.expect("current namespace should be removed for the foreign-state check");
|
||||
|
||||
seed_record(
|
||||
state.path(),
|
||||
foreign_games.path(),
|
||||
&["archive.eti"],
|
||||
Some(&["archive.eti"]),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::Untracked
|
||||
);
|
||||
assert_eq!(
|
||||
readiness(foreign_games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::RecoveryRequired
|
||||
);
|
||||
|
||||
write_file(&record_path, b"not json");
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::RecoveryRequired
|
||||
);
|
||||
|
||||
let invalid = DownloadOwnershipRecord {
|
||||
schema_version: OWNERSHIP_SCHEMA_VERSION + 1,
|
||||
game_id: "game".to_owned(),
|
||||
games_folder_key: games_folder_key(games.path()),
|
||||
committed_content_id: Some(test_content_id()),
|
||||
committed_files: vec!["archive.eti".to_owned()],
|
||||
pending_content_id: None,
|
||||
pending_files: None,
|
||||
};
|
||||
write_file(
|
||||
&record_path,
|
||||
&serde_json::to_vec(&invalid).expect("invalid fixture should encode"),
|
||||
);
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::RecoveryRequired
|
||||
);
|
||||
|
||||
std::fs::remove_file(&record_path).expect("record should be removed");
|
||||
std::fs::create_dir(&record_path).expect("non-file record should be created");
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::RecoveryRequired
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pre_content_id_ownership_record_is_never_catalog_verified() {
|
||||
let games = TempDir::new("lanspread-ownership-old-schema-games");
|
||||
let state = TempDir::new("lanspread-ownership-old-schema-state");
|
||||
let old_record = serde_json::json!({
|
||||
"schema_version": 1,
|
||||
"game_id": "game",
|
||||
"games_folder_key": games_folder_key(games.path()),
|
||||
"committed_files": ["archive.eti"],
|
||||
"pending_files": null,
|
||||
});
|
||||
write_file(
|
||||
&ownership_record_path(state.path(), games.path()),
|
||||
&serde_json::to_vec_pretty(&old_record).expect("old record should encode"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::RecoveryRequired
|
||||
);
|
||||
assert!(
|
||||
!download_ownership_matches_content(
|
||||
games.path(),
|
||||
state.path(),
|
||||
"game",
|
||||
test_content_id(),
|
||||
)
|
||||
.await
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_root_namespace_survives_another_root_and_recovers_when_selected_again() {
|
||||
let old_games = TempDir::new("lanspread-ownership-old-marker-root");
|
||||
let new_games = TempDir::new("lanspread-ownership-new-marker-root");
|
||||
let state = TempDir::new("lanspread-ownership-foreign-marker-state");
|
||||
seed_record(
|
||||
state.path(),
|
||||
old_games.path(),
|
||||
&["old.eti"],
|
||||
Some(&["pending.eti"]),
|
||||
)
|
||||
.await;
|
||||
let marker_path = ownership_marker_path(state.path(), old_games.path());
|
||||
create_recovery_marker(&marker_path).expect("foreign recovery marker should publish");
|
||||
let old_record_path = ownership_record_path(state.path(), old_games.path());
|
||||
let old_record_before = std::fs::read(&old_record_path).expect("old record should exist");
|
||||
let old_marker_before = std::fs::read(&marker_path).expect("old marker should exist");
|
||||
|
||||
let manifest = manifest(new_games.path(), &[]);
|
||||
let game_root = confined_root(&manifest);
|
||||
DownloadOwnershipTransaction::prepare(state.path(), &manifest, &game_root)
|
||||
.await
|
||||
.expect("new-root baseline should not inspect foreign ownership contents");
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(&old_record_path).expect("old record should survive"),
|
||||
old_record_before
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&marker_path).expect("old marker should survive"),
|
||||
old_marker_before
|
||||
);
|
||||
assert_eq!(
|
||||
readiness(new_games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::Settled
|
||||
);
|
||||
let record = read_valid_record(state.path(), new_games.path()).await;
|
||||
assert!(record.committed_files.is_empty());
|
||||
assert!(record.pending_files.is_none());
|
||||
|
||||
recover_incomplete_download(&old_games.game_root(), state.path(), "game")
|
||||
.await
|
||||
.expect("selecting the old absent root should recover its pending state");
|
||||
let recovered = read_valid_record(state.path(), old_games.path()).await;
|
||||
assert!(recovered.committed_files.is_empty());
|
||||
assert!(recovered.pending_files.is_none());
|
||||
assert!(!marker_path.exists());
|
||||
assert_eq!(
|
||||
readiness(new_games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::Settled
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settled_roots_retain_independent_removal_authority() {
|
||||
let games_a = TempDir::new("lanspread-ownership-removal-root-a");
|
||||
let games_b = TempDir::new("lanspread-ownership-removal-root-b");
|
||||
let state = TempDir::new("lanspread-ownership-removal-roots-state");
|
||||
for games in [&games_a, &games_b] {
|
||||
write_file(&games.game_root().join(VERSION_INI), b"20240101");
|
||||
write_file(&games.game_root().join("archive.eti"), b"owned");
|
||||
seed_record(state.path(), games.path(), &["archive.eti"], None).await;
|
||||
}
|
||||
|
||||
remove_downloaded_payload(games_a.path(), state.path(), "game")
|
||||
.await
|
||||
.expect("root A ownership should authorize only root A removal");
|
||||
assert!(!games_a.game_root().join("archive.eti").exists());
|
||||
assert_eq!(
|
||||
std::fs::read(games_b.game_root().join("archive.eti"))
|
||||
.expect("root B payload should remain"),
|
||||
b"owned"
|
||||
);
|
||||
assert_eq!(
|
||||
read_valid_record(state.path(), games_b.path())
|
||||
.await
|
||||
.committed_files,
|
||||
["archive.eti"]
|
||||
);
|
||||
|
||||
remove_downloaded_payload(games_b.path(), state.path(), "game")
|
||||
.await
|
||||
.expect("root B ownership should remain independently removable");
|
||||
assert!(!games_b.game_root().join("archive.eti").exists());
|
||||
assert!(
|
||||
read_valid_record(state.path(), games_a.path())
|
||||
.await
|
||||
.committed_files
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
read_valid_record(state.path(), games_b.path())
|
||||
.await
|
||||
.committed_files
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selected_namespace_rejects_a_record_from_another_root_without_mutation() {
|
||||
let games_a = TempDir::new("lanspread-ownership-misplaced-root-a");
|
||||
let games_b = TempDir::new("lanspread-ownership-misplaced-root-b");
|
||||
let state = TempDir::new("lanspread-ownership-misplaced-state");
|
||||
seed_record(state.path(), games_a.path(), &["archive.eti"], None).await;
|
||||
let record_a = ownership_record_path(state.path(), games_a.path());
|
||||
let bytes_a = std::fs::read(&record_a).expect("root A record should be readable");
|
||||
let record_b = ownership_record_path(state.path(), games_b.path());
|
||||
write_file(&record_b, &bytes_a);
|
||||
let marker_b = ownership_marker_path(state.path(), games_b.path());
|
||||
create_recovery_marker(&marker_b).expect("root B marker should publish");
|
||||
let marker_before = std::fs::read(&marker_b).expect("root B marker should be readable");
|
||||
|
||||
assert!(
|
||||
scan_download_ownership_recovery_ids(state.path(), games_b.path()).is_err(),
|
||||
"the digest is only an index; the full root key remains authority"
|
||||
);
|
||||
assert_eq!(
|
||||
readiness(games_b.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::RecoveryRequired
|
||||
);
|
||||
let manifest = manifest(games_b.path(), &[]);
|
||||
let game_root = confined_root(&manifest);
|
||||
let error = DownloadOwnershipTransaction::prepare(state.path(), &manifest, &game_root)
|
||||
.await
|
||||
.expect_err("a misplaced record must never become a fresh baseline");
|
||||
assert!(error.to_string().contains("different games directory"));
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(&record_a).expect("root A record should remain"),
|
||||
bytes_a
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&record_b).expect("misplaced record should remain as evidence"),
|
||||
bytes_a
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&marker_b).expect("marker should remain as evidence"),
|
||||
marker_before
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_namespace_portable_alias_fails_closed_without_mutation() {
|
||||
let games = TempDir::new("lanspread-ownership-namespace-alias-games");
|
||||
let state = TempDir::new("lanspread-ownership-namespace-alias-state");
|
||||
let namespace =
|
||||
download_ownership_namespace_dir(state.path(), "game", &games_folder_key(games.path()));
|
||||
let alias = namespace
|
||||
.file_name()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.expect("namespace should have a UTF-8 name")
|
||||
.to_ascii_uppercase();
|
||||
let alias_path = namespace
|
||||
.parent()
|
||||
.expect("namespace should have a parent")
|
||||
.join(alias);
|
||||
write_file(&alias_path.join("canary"), b"preserve");
|
||||
|
||||
assert!(
|
||||
scan_download_ownership_recovery_ids(state.path(), games.path()).is_err(),
|
||||
"an alias of the selected namespace must be rejected before recovery"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(alias_path.join("canary")).expect("canary should remain"),
|
||||
b"preserve"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn selected_namespace_symlink_is_rejected_without_following_it() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let games = TempDir::new("lanspread-ownership-namespace-link-games");
|
||||
let state = TempDir::new("lanspread-ownership-namespace-link-state");
|
||||
let outside = TempDir::new("lanspread-ownership-namespace-link-outside");
|
||||
let namespace =
|
||||
download_ownership_namespace_dir(state.path(), "game", &games_folder_key(games.path()));
|
||||
std::fs::create_dir_all(namespace.parent().expect("namespace should have a parent"))
|
||||
.expect("namespace parent should be created");
|
||||
write_file(&outside.path().join("canary"), b"outside");
|
||||
symlink(outside.path(), &namespace).expect("selected namespace link should be created");
|
||||
|
||||
assert!(scan_download_ownership_recovery_ids(state.path(), games.path()).is_err());
|
||||
assert_eq!(
|
||||
std::fs::read(outside.path().join("canary")).expect("outside canary should remain"),
|
||||
b"outside"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_pending_state_migrates_to_its_bound_root_while_another_root_is_selected() {
|
||||
let games_a = TempDir::new("lanspread-ownership-legacy-root-a");
|
||||
let games_b = TempDir::new("lanspread-ownership-legacy-root-b");
|
||||
let state = TempDir::new("lanspread-ownership-legacy-state");
|
||||
seed_legacy_record(
|
||||
state.path(),
|
||||
games_a.path(),
|
||||
&["old.eti"],
|
||||
Some(&["pending.eti"]),
|
||||
true,
|
||||
);
|
||||
let legacy_path = legacy_download_ownership_path(state.path(), "game");
|
||||
let legacy_before = std::fs::read(&legacy_path).expect("legacy record should exist");
|
||||
|
||||
assert_eq!(
|
||||
scan_download_ownership_recovery_ids(state.path(), games_b.path())
|
||||
.expect("valid legacy state should be scheduled for migration"),
|
||||
HashSet::from(["game".to_owned()])
|
||||
);
|
||||
recover_incomplete_download(&games_b.game_root(), state.path(), "game")
|
||||
.await
|
||||
.expect("migration should use the legacy record's own root binding");
|
||||
|
||||
assert!(!legacy_path.exists());
|
||||
assert!(!legacy_download_ownership_tmp_path(state.path(), "game").exists());
|
||||
assert!(!legacy_download_ownership_recovery_required_path(state.path(), "game").exists());
|
||||
assert_eq!(
|
||||
std::fs::read(ownership_record_path(state.path(), games_a.path()))
|
||||
.expect("namespaced record should exist"),
|
||||
legacy_before
|
||||
);
|
||||
assert!(ownership_marker_path(state.path(), games_a.path()).is_file());
|
||||
assert_eq!(
|
||||
readiness(games_b.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::Untracked
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exact_legacy_migration_duplicate_resumes_after_marker_cleanup_crash() {
|
||||
let games = TempDir::new("lanspread-ownership-legacy-split-games");
|
||||
let state = TempDir::new("lanspread-ownership-legacy-split-state");
|
||||
seed_legacy_record(state.path(), games.path(), &["archive.eti"], None, true);
|
||||
seed_record(state.path(), games.path(), &["archive.eti"], None).await;
|
||||
create_recovery_marker(&ownership_marker_path(state.path(), games.path()))
|
||||
.expect("destination marker should represent the split migration");
|
||||
std::fs::remove_file(legacy_download_ownership_recovery_required_path(
|
||||
state.path(),
|
||||
"game",
|
||||
))
|
||||
.expect("legacy marker cleanup should be represented");
|
||||
|
||||
recover_incomplete_download(&games.game_root(), state.path(), "game")
|
||||
.await
|
||||
.expect("an exact duplicate should finish migration and selected-root recovery");
|
||||
|
||||
assert!(!legacy_download_ownership_path(state.path(), "game").exists());
|
||||
assert!(!ownership_marker_path(state.path(), games.path()).exists());
|
||||
assert_eq!(
|
||||
read_valid_record(state.path(), games.path())
|
||||
.await
|
||||
.committed_files,
|
||||
["archive.eti"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_legacy_state_fails_closed_without_mutation() {
|
||||
let games = TempDir::new("lanspread-ownership-legacy-invalid-games");
|
||||
let state = TempDir::new("lanspread-ownership-legacy-invalid-state");
|
||||
let marker = legacy_download_ownership_recovery_required_path(state.path(), "game");
|
||||
write_file(&marker, RECOVERY_MARKER_BYTES);
|
||||
let before = std::fs::read(&marker).expect("legacy marker should be readable");
|
||||
|
||||
assert!(scan_download_ownership_recovery_ids(state.path(), games.path()).is_err());
|
||||
assert_eq!(
|
||||
std::fs::read(&marker).expect("ambiguous marker should be preserved"),
|
||||
before
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_and_namespaced_conflict_is_zero_mutation() {
|
||||
let games = TempDir::new("lanspread-ownership-legacy-conflict-games");
|
||||
let state = TempDir::new("lanspread-ownership-legacy-conflict-state");
|
||||
seed_legacy_record(state.path(), games.path(), &["legacy.eti"], None, false);
|
||||
seed_record(state.path(), games.path(), &["namespaced.eti"], None).await;
|
||||
let legacy_path = legacy_download_ownership_path(state.path(), "game");
|
||||
let namespaced_path = ownership_record_path(state.path(), games.path());
|
||||
let legacy_before = std::fs::read(&legacy_path).expect("legacy record should exist");
|
||||
let namespaced_before =
|
||||
std::fs::read(&namespaced_path).expect("namespaced record should exist");
|
||||
|
||||
assert!(scan_download_ownership_recovery_ids(state.path(), games.path()).is_err());
|
||||
assert!(
|
||||
recover_incomplete_download(&games.game_root(), state.path(), "game")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&legacy_path).expect("legacy evidence should remain"),
|
||||
legacy_before
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&namespaced_path).expect("destination evidence should remain"),
|
||||
namespaced_before
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepublication_tmp_only_state_is_discovered_and_swept() {
|
||||
let games = TempDir::new("lanspread-ownership-tmp-only-games");
|
||||
let state = TempDir::new("lanspread-ownership-tmp-only-state");
|
||||
let tmp = ownership_tmp_path(state.path(), games.path());
|
||||
let legacy_tmp = legacy_download_ownership_tmp_path(state.path(), "game");
|
||||
write_file(&tmp, b"partial");
|
||||
write_file(&legacy_tmp, b"legacy-partial");
|
||||
|
||||
assert_eq!(
|
||||
scan_download_ownership_recovery_ids(state.path(), games.path())
|
||||
.expect("safe temporary state should scan"),
|
||||
HashSet::from(["game".to_owned()])
|
||||
);
|
||||
recover_incomplete_download(&games.game_root(), state.path(), "game")
|
||||
.await
|
||||
.expect("prepublication scratch should be safely swept");
|
||||
assert!(!tmp.exists());
|
||||
assert!(!legacy_tmp.exists());
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::Untracked
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn unreadable_current_record_requires_recovery() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let games = TempDir::new("lanspread-ownership-unreadable-games");
|
||||
let state = TempDir::new("lanspread-ownership-unreadable-state");
|
||||
seed_record(state.path(), games.path(), &["archive.eti"], None).await;
|
||||
let record_path = ownership_record_path(state.path(), games.path());
|
||||
std::fs::set_permissions(&record_path, std::fs::Permissions::from_mode(0o000))
|
||||
.expect("record should become unreadable");
|
||||
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::RecoveryRequired
|
||||
);
|
||||
|
||||
std::fs::set_permissions(&record_path, std::fs::Permissions::from_mode(0o600))
|
||||
.expect("test cleanup should restore record permissions");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn journal_is_sorted_bound_and_ignores_stray_tmp() {
|
||||
let games = TempDir::new("lanspread-ownership-games");
|
||||
let state = TempDir::new("lanspread-ownership-state");
|
||||
let manifest = manifest(games.path(), &["z.eti", "nested/a.bin"]);
|
||||
let game_root = confined_root(&manifest).await;
|
||||
let expected_content_id = manifest.catalog_manifest().content_id();
|
||||
let game_root = confined_root(&manifest);
|
||||
let transaction =
|
||||
DownloadOwnershipTransaction::prepare(state.path(), &manifest, &game_root)
|
||||
.await
|
||||
.expect("transaction should prepare");
|
||||
transaction
|
||||
let publication = transaction
|
||||
.journal_pending()
|
||||
.await
|
||||
.expect("pending set should be durable");
|
||||
assert!(matches!(publication, OwnershipJournalPublication::Durable));
|
||||
assert!(
|
||||
ownership_marker_path(state.path(), games.path()).is_file(),
|
||||
"the quarantine must span all payload mutation"
|
||||
);
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::RecoveryRequired
|
||||
);
|
||||
assert!(
|
||||
!download_ownership_matches_content(
|
||||
games.path(),
|
||||
state.path(),
|
||||
"game",
|
||||
expected_content_id,
|
||||
)
|
||||
.await
|
||||
);
|
||||
|
||||
let record = read_valid_record(state.path(), games.path()).await;
|
||||
assert_eq!(
|
||||
record.pending_files,
|
||||
Some(vec!["nested/a.bin".to_owned(), "z.eti".to_owned()])
|
||||
);
|
||||
assert_eq!(record.pending_content_id, Some(expected_content_id));
|
||||
assert!(record.committed_content_id.is_none());
|
||||
assert!(record.committed_files.is_empty());
|
||||
assert_eq!(record.game_id, "game");
|
||||
assert_eq!(record.games_folder_key, games_folder_key(games.path()));
|
||||
|
||||
transaction
|
||||
let publication = transaction
|
||||
.finalize()
|
||||
.await
|
||||
.expect("record should finalize");
|
||||
write_file(
|
||||
&download_ownership_tmp_path(state.path(), "game"),
|
||||
b"not json",
|
||||
assert!(matches!(publication, OwnershipJournalPublication::Durable));
|
||||
assert!(!ownership_marker_path(state.path(), games.path()).exists());
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::Settled
|
||||
);
|
||||
write_file(&ownership_tmp_path(state.path(), games.path()), b"not json");
|
||||
let stable = read_valid_record(state.path(), games.path()).await;
|
||||
assert_eq!(stable.committed_content_id, Some(expected_content_id));
|
||||
assert_eq!(stable.committed_files, ["nested/a.bin", "z.eti"]);
|
||||
assert!(stable.pending_content_id.is_none());
|
||||
assert!(stable.pending_files.is_none());
|
||||
assert!(
|
||||
download_ownership_matches_content(
|
||||
games.path(),
|
||||
state.path(),
|
||||
"game",
|
||||
expected_content_id,
|
||||
)
|
||||
.await
|
||||
);
|
||||
|
||||
let different_content_id = manifest_with_version(games.path(), &[], "20250102")
|
||||
.catalog_manifest()
|
||||
.content_id();
|
||||
assert!(
|
||||
!download_ownership_matches_content(
|
||||
games.path(),
|
||||
state.path(),
|
||||
"game",
|
||||
different_content_id,
|
||||
)
|
||||
.await
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -834,7 +2877,9 @@ mod tests {
|
||||
schema_version: OWNERSHIP_SCHEMA_VERSION,
|
||||
game_id: "game".to_owned(),
|
||||
games_folder_key: "root".to_owned(),
|
||||
committed_content_id: Some(test_content_id()),
|
||||
committed_files: vec!["archive.eti".to_owned()],
|
||||
pending_content_id: None,
|
||||
pending_files: None,
|
||||
};
|
||||
assert!(valid.clone().validate("game", "root").is_ok());
|
||||
@@ -859,9 +2904,35 @@ mod tests {
|
||||
|
||||
let mut cross_generation_alias = valid.clone();
|
||||
cross_generation_alias.committed_files = vec!["Archive.eti".to_owned()];
|
||||
cross_generation_alias.pending_content_id = Some(ContentId::from_bytes([0x24; 32]));
|
||||
cross_generation_alias.pending_files = Some(vec!["archive.eti".to_owned()]);
|
||||
assert!(cross_generation_alias.validate("game", "root").is_err());
|
||||
|
||||
let mut unverified_committed = valid.clone();
|
||||
unverified_committed.committed_content_id = None;
|
||||
assert!(unverified_committed.validate("game", "root").is_err());
|
||||
|
||||
let mut unverified_pending = valid.clone();
|
||||
unverified_pending.pending_files = Some(vec!["new.eti".to_owned()]);
|
||||
assert!(unverified_pending.validate("game", "root").is_err());
|
||||
|
||||
let mut detached_pending_id = valid.clone();
|
||||
detached_pending_id.pending_content_id = Some(ContentId::from_bytes([0x24; 32]));
|
||||
assert!(detached_pending_id.validate("game", "root").is_err());
|
||||
|
||||
let mut removal_intent = valid.clone();
|
||||
removal_intent.pending_files = Some(Vec::new());
|
||||
assert!(removal_intent.validate("game", "root").is_ok());
|
||||
|
||||
let mut version_only_committed = valid.clone();
|
||||
version_only_committed.committed_files.clear();
|
||||
assert!(version_only_committed.validate("game", "root").is_ok());
|
||||
|
||||
let mut version_only_pending = valid.clone();
|
||||
version_only_pending.pending_content_id = Some(ContentId::from_bytes([0x24; 32]));
|
||||
version_only_pending.pending_files = Some(Vec::new());
|
||||
assert!(version_only_pending.validate("game", "root").is_ok());
|
||||
|
||||
assert!(valid.clone().validate("other", "root").is_err());
|
||||
assert!(valid.validate("game", "other-root").is_err());
|
||||
}
|
||||
@@ -875,7 +2946,7 @@ mod tests {
|
||||
write_file(&root.join("archive.eti"), b"user-bytes");
|
||||
write_file(&root.join("notes.txt"), b"user-note");
|
||||
let manifest = manifest(games.path(), &["archive.eti"]);
|
||||
let confined_root = confined_root(&manifest).await;
|
||||
let confined_root = confined_root(&manifest);
|
||||
|
||||
let error = DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root)
|
||||
.await
|
||||
@@ -895,7 +2966,7 @@ mod tests {
|
||||
b"user-note"
|
||||
);
|
||||
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
|
||||
assert!(!download_ownership_path(state.path(), "game").exists());
|
||||
assert!(!ownership_record_path(state.path(), games.path()).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -922,13 +2993,12 @@ mod tests {
|
||||
.await;
|
||||
|
||||
let manifest = manifest(games.path(), &["keep.eti", "new.eti"]);
|
||||
let confined_root = confined_root(&manifest).await;
|
||||
let confined_root = confined_root(&manifest);
|
||||
let transaction =
|
||||
DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root)
|
||||
.await
|
||||
.expect("transaction should prepare");
|
||||
super::super::version_ini::begin_version_ini_transaction(&confined_root)
|
||||
.await
|
||||
.expect("sentinel should park");
|
||||
transaction
|
||||
.journal_pending()
|
||||
@@ -936,7 +3006,6 @@ mod tests {
|
||||
.expect("pending should journal");
|
||||
transaction
|
||||
.remove_stale()
|
||||
.await
|
||||
.expect("stale cleanup should succeed");
|
||||
|
||||
assert!(root.join("keep.eti").is_file());
|
||||
@@ -966,7 +3035,9 @@ mod tests {
|
||||
b"save"
|
||||
);
|
||||
let record = read_valid_record(state.path(), games.path()).await;
|
||||
assert!(record.committed_content_id.is_none());
|
||||
assert!(record.committed_files.is_empty());
|
||||
assert!(record.pending_content_id.is_none());
|
||||
assert!(record.pending_files.is_none());
|
||||
}
|
||||
|
||||
@@ -1009,7 +3080,9 @@ mod tests {
|
||||
b"user"
|
||||
);
|
||||
let record = read_valid_record(state.path(), games.path()).await;
|
||||
assert!(record.committed_content_id.is_none());
|
||||
assert!(record.committed_files.is_empty());
|
||||
assert!(record.pending_content_id.is_none());
|
||||
assert!(record.pending_files.is_none());
|
||||
|
||||
remove_downloaded_payload(games.path(), state.path(), "game")
|
||||
@@ -1021,6 +3094,185 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removal_clears_a_version_only_content_binding_without_a_sentinel() {
|
||||
let games = TempDir::new("lanspread-ownership-remove-version-only-games");
|
||||
let state = TempDir::new("lanspread-ownership-remove-version-only-state");
|
||||
std::fs::create_dir(games.game_root()).expect("empty game root should be created");
|
||||
let content_id = test_content_id();
|
||||
seed_record_with_content_ids(
|
||||
state.path(),
|
||||
games.path(),
|
||||
&[],
|
||||
Some(content_id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
download_ownership_matches_content(games.path(), state.path(), "game", content_id,)
|
||||
.await
|
||||
);
|
||||
|
||||
remove_downloaded_payload(games.path(), state.path(), "game")
|
||||
.await
|
||||
.expect("version-only ownership should still be removable");
|
||||
|
||||
let record = read_valid_record(state.path(), games.path()).await;
|
||||
assert!(record.committed_content_id.is_none());
|
||||
assert!(record.committed_files.is_empty());
|
||||
assert!(record.pending_content_id.is_none());
|
||||
assert!(record.pending_files.is_none());
|
||||
assert!(
|
||||
!download_ownership_matches_content(games.path(), state.path(), "game", content_id,)
|
||||
.await
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn absent_root_clears_a_version_only_content_binding() {
|
||||
let games = TempDir::new("lanspread-ownership-remove-absent-version-only-games");
|
||||
let state = TempDir::new("lanspread-ownership-remove-absent-version-only-state");
|
||||
let content_id = test_content_id();
|
||||
seed_record_with_content_ids(
|
||||
state.path(),
|
||||
games.path(),
|
||||
&[],
|
||||
Some(content_id),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
remove_downloaded_payload(games.path(), state.path(), "game")
|
||||
.await
|
||||
.expect("an absent version-only root should settle ownership");
|
||||
|
||||
let record = read_valid_record(state.path(), games.path()).await;
|
||||
assert!(record.committed_content_id.is_none());
|
||||
assert!(record.committed_files.is_empty());
|
||||
assert!(record.pending_content_id.is_none());
|
||||
assert!(record.pending_files.is_none());
|
||||
assert!(
|
||||
!download_ownership_matches_content(games.path(), state.path(), "game", content_id,)
|
||||
.await
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn absent_root_quarantines_a_marker_without_a_record() {
|
||||
let games = TempDir::new("lanspread-ownership-remove-absent-missing-games");
|
||||
let state = TempDir::new("lanspread-ownership-remove-absent-missing-state");
|
||||
let record_path = ownership_record_path(state.path(), games.path());
|
||||
let marker_path = ownership_marker_path(state.path(), games.path());
|
||||
create_recovery_marker(&marker_path).expect("marker should publish");
|
||||
let marker_before = std::fs::read(&marker_path).expect("marker should be readable");
|
||||
|
||||
let error = remove_downloaded_payload(games.path(), state.path(), "game")
|
||||
.await
|
||||
.expect_err("marker-only state must stay quarantined");
|
||||
|
||||
assert!(error.to_string().contains("without a valid record"));
|
||||
assert!(!record_path.exists());
|
||||
assert_eq!(
|
||||
std::fs::read(&marker_path).expect("marker should be preserved"),
|
||||
marker_before
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn absent_root_preserves_a_foreign_record_and_marker() {
|
||||
let games = TempDir::new("lanspread-ownership-remove-absent-current-games");
|
||||
let foreign_games = TempDir::new("lanspread-ownership-remove-absent-foreign-games");
|
||||
let state = TempDir::new("lanspread-ownership-remove-absent-foreign-state");
|
||||
seed_record(
|
||||
state.path(),
|
||||
foreign_games.path(),
|
||||
&["archive.eti"],
|
||||
Some(&["partial.eti"]),
|
||||
)
|
||||
.await;
|
||||
let record_path = ownership_record_path(state.path(), foreign_games.path());
|
||||
let marker_path = ownership_marker_path(state.path(), foreign_games.path());
|
||||
create_recovery_marker(&marker_path).expect("foreign marker should publish");
|
||||
let record_before = std::fs::read(&record_path).expect("record should be readable");
|
||||
let marker_before = std::fs::read(&marker_path).expect("marker should be readable");
|
||||
|
||||
remove_downloaded_payload(games.path(), state.path(), "game")
|
||||
.await
|
||||
.expect("an absent root must not settle foreign state");
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(&record_path).expect("foreign record should be preserved"),
|
||||
record_before
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&marker_path).expect("foreign marker should be preserved"),
|
||||
marker_before
|
||||
);
|
||||
assert!(matches!(
|
||||
load_record(&record_path, "game", &games_folder_key(games.path())),
|
||||
LoadedOwnership::Foreign
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn absent_root_rejects_invalid_state_without_clearing_its_marker() {
|
||||
let games = TempDir::new("lanspread-ownership-remove-absent-invalid-games");
|
||||
let state = TempDir::new("lanspread-ownership-remove-absent-invalid-state");
|
||||
let record_path = ownership_record_path(state.path(), games.path());
|
||||
let marker_path = ownership_marker_path(state.path(), games.path());
|
||||
write_file(&record_path, b"not-json");
|
||||
create_recovery_marker(&marker_path).expect("marker should publish");
|
||||
let marker_before = std::fs::read(&marker_path).expect("marker should be readable");
|
||||
|
||||
let error = remove_downloaded_payload(games.path(), state.path(), "game")
|
||||
.await
|
||||
.expect_err("invalid state must not be silently settled");
|
||||
|
||||
assert!(error.to_string().contains("invalid record"));
|
||||
assert_eq!(
|
||||
std::fs::read(&record_path).expect("invalid record should be preserved"),
|
||||
b"not-json"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(&marker_path).expect("marker should be preserved"),
|
||||
marker_before
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn absent_root_settles_only_current_bound_valid_state() {
|
||||
let games = TempDir::new("lanspread-ownership-remove-absent-valid-games");
|
||||
let state = TempDir::new("lanspread-ownership-remove-absent-valid-state");
|
||||
seed_record(
|
||||
state.path(),
|
||||
games.path(),
|
||||
&["archive.eti"],
|
||||
Some(&["partial.eti"]),
|
||||
)
|
||||
.await;
|
||||
let marker_path = ownership_marker_path(state.path(), games.path());
|
||||
create_recovery_marker(&marker_path).expect("current marker should publish");
|
||||
|
||||
assert_eq!(
|
||||
scan_download_ownership_recovery_ids(state.path(), games.path())
|
||||
.expect("ownership-only state should scan"),
|
||||
HashSet::from(["game".to_owned()]),
|
||||
"startup recovery must discover pending state without a game directory"
|
||||
);
|
||||
|
||||
remove_downloaded_payload(games.path(), state.path(), "game")
|
||||
.await
|
||||
.expect("current-bound state should settle when its root is absent");
|
||||
|
||||
let record = read_valid_record(state.path(), games.path()).await;
|
||||
assert!(record.committed_files.is_empty());
|
||||
assert!(record.pending_files.is_none());
|
||||
assert!(!marker_path.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removal_without_trustworthy_ownership_is_zero_mutation() {
|
||||
for invalid_record in [None, Some(b"not-json".as_slice())] {
|
||||
@@ -1031,14 +3283,17 @@ mod tests {
|
||||
write_file(&root.join("archive.eti"), b"ambiguous");
|
||||
write_file(&root.join("notes.txt"), b"user");
|
||||
if let Some(bytes) = invalid_record {
|
||||
write_file(&download_ownership_path(state.path(), "game"), bytes);
|
||||
write_file(&ownership_record_path(state.path(), games.path()), bytes);
|
||||
}
|
||||
|
||||
let error = remove_downloaded_payload(games.path(), state.path(), "game")
|
||||
.await
|
||||
.expect_err("ambiguous payload must not be removed");
|
||||
|
||||
assert!(error.to_string().contains("cannot safely remove"));
|
||||
assert!(
|
||||
error.to_string().contains("cannot safely remove")
|
||||
|| error.to_string().contains("invalid record")
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(root.join(VERSION_INI)).expect("sentinel should remain"),
|
||||
b"20240101"
|
||||
@@ -1104,102 +3359,132 @@ mod tests {
|
||||
b"user"
|
||||
);
|
||||
let record = read_valid_record(state.path(), games.path()).await;
|
||||
assert!(record.committed_content_id.is_none());
|
||||
assert!(record.committed_files.is_empty());
|
||||
assert!(record.pending_content_id.is_none());
|
||||
assert!(record.pending_files.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_handles_every_durable_journal_state() {
|
||||
// Crash after parking the old sentinel but before publishing pending.
|
||||
{
|
||||
let games = TempDir::new("lanspread-ownership-unrecorded-games");
|
||||
let state = TempDir::new("lanspread-ownership-unrecorded-state");
|
||||
let root = games.game_root();
|
||||
write_file(&root.join(VERSION_INI), b"20240101");
|
||||
let manifest = manifest(games.path(), &["archive.eti"]);
|
||||
let confined_root = confined_root(&manifest).await;
|
||||
let _transaction =
|
||||
DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root)
|
||||
.await
|
||||
.expect("baseline ownership should be durable");
|
||||
super::super::version_ini::begin_version_ini_transaction(&confined_root)
|
||||
async fn recovery_restores_an_unjournaled_parked_sentinel() {
|
||||
let games = TempDir::new("lanspread-ownership-unrecorded-games");
|
||||
let state = TempDir::new("lanspread-ownership-unrecorded-state");
|
||||
let root = games.game_root();
|
||||
write_file(&root.join(VERSION_INI), b"20240101");
|
||||
let manifest = manifest(games.path(), &["archive.eti"]);
|
||||
let confined_root = confined_root(&manifest);
|
||||
let _transaction =
|
||||
DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root)
|
||||
.await
|
||||
.expect("sentinel should park");
|
||||
recover_incomplete_download(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("unjournaled park should recover");
|
||||
assert_eq!(
|
||||
std::fs::read(root.join(VERSION_INI)).expect("sentinel should be readable"),
|
||||
b"20240101"
|
||||
);
|
||||
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
|
||||
}
|
||||
.expect("baseline ownership should be durable");
|
||||
super::super::version_ini::begin_version_ini_transaction(&confined_root)
|
||||
.expect("sentinel should park");
|
||||
|
||||
// Pending without a sentinel means the transaction never committed.
|
||||
{
|
||||
let games = TempDir::new("lanspread-ownership-abort-games");
|
||||
let state = TempDir::new("lanspread-ownership-abort-state");
|
||||
let root = games.game_root();
|
||||
write_file(&root.join("old.eti"), b"old");
|
||||
write_file(&root.join("new.eti"), b"partial");
|
||||
write_file(&root.join("notes.txt"), b"user");
|
||||
write_file(&root.join(LOCAL_DIR).join("save.dat"), b"save");
|
||||
write_file(&root.join(VERSION_DISCARDED_FILE), b"20240101");
|
||||
seed_record(state.path(), games.path(), &["old.eti"], Some(&["new.eti"])).await;
|
||||
recover_incomplete_download(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("pending abort should recover");
|
||||
assert!(!root.join("old.eti").exists());
|
||||
assert!(!root.join("new.eti").exists());
|
||||
assert!(!root.join(VERSION_INI).exists());
|
||||
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("notes.txt")).expect("user file should remain readable"),
|
||||
b"user"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(root.join(LOCAL_DIR).join("save.dat"))
|
||||
.expect("local save should remain readable"),
|
||||
b"save"
|
||||
);
|
||||
let record = read_valid_record(state.path(), games.path()).await;
|
||||
assert!(record.committed_files.is_empty());
|
||||
assert!(record.pending_files.is_none());
|
||||
}
|
||||
recover_incomplete_download(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("unjournaled park should recover");
|
||||
|
||||
// Pending with a sentinel means commit landed before ledger finalization.
|
||||
{
|
||||
let games = TempDir::new("lanspread-ownership-commit-games");
|
||||
let state = TempDir::new("lanspread-ownership-commit-state");
|
||||
let root = games.game_root();
|
||||
for path in ["keep.eti", "new.eti", "stale.eti", "notes.txt"] {
|
||||
write_file(&root.join(path), path.as_bytes());
|
||||
}
|
||||
write_file(&root.join(VERSION_INI), b"20250101");
|
||||
write_file(&root.join(VERSION_DISCARDED_FILE), b"20240101");
|
||||
seed_record(
|
||||
state.path(),
|
||||
assert_eq!(
|
||||
std::fs::read(root.join(VERSION_INI)).expect("sentinel should be readable"),
|
||||
b"20240101"
|
||||
);
|
||||
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_aborts_a_pending_generation_without_a_sentinel() {
|
||||
let games = TempDir::new("lanspread-ownership-abort-games");
|
||||
let state = TempDir::new("lanspread-ownership-abort-state");
|
||||
let root = games.game_root();
|
||||
write_file(&root.join("old.eti"), b"old");
|
||||
write_file(&root.join("new.eti"), b"partial");
|
||||
write_file(&root.join("notes.txt"), b"user");
|
||||
write_file(&root.join(LOCAL_DIR).join("save.dat"), b"save");
|
||||
write_file(&root.join(VERSION_DISCARDED_FILE), b"20240101");
|
||||
seed_record(state.path(), games.path(), &["old.eti"], Some(&["new.eti"])).await;
|
||||
|
||||
recover_incomplete_download(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("pending abort should recover");
|
||||
|
||||
assert!(!root.join("old.eti").exists());
|
||||
assert!(!root.join("new.eti").exists());
|
||||
assert!(!root.join(VERSION_INI).exists());
|
||||
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("notes.txt")).expect("user file should remain readable"),
|
||||
b"user"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(root.join(LOCAL_DIR).join("save.dat"))
|
||||
.expect("local save should remain readable"),
|
||||
b"save"
|
||||
);
|
||||
let record = read_valid_record(state.path(), games.path()).await;
|
||||
assert!(record.committed_content_id.is_none());
|
||||
assert!(record.committed_files.is_empty());
|
||||
assert!(record.pending_content_id.is_none());
|
||||
assert!(record.pending_files.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_promotes_the_pending_content_id_after_sentinel_commit() {
|
||||
let games = TempDir::new("lanspread-ownership-commit-games");
|
||||
let state = TempDir::new("lanspread-ownership-commit-state");
|
||||
let root = games.game_root();
|
||||
for path in ["keep.eti", "new.eti", "stale.eti", "notes.txt"] {
|
||||
write_file(&root.join(path), path.as_bytes());
|
||||
}
|
||||
write_file(&root.join(VERSION_INI), b"20250101");
|
||||
write_file(&root.join(VERSION_DISCARDED_FILE), b"20240101");
|
||||
let committed_content_id = ContentId::from_bytes([0x11; 32]);
|
||||
let pending_content_id = ContentId::from_bytes([0x22; 32]);
|
||||
seed_record_with_content_ids(
|
||||
state.path(),
|
||||
games.path(),
|
||||
&["keep.eti", "stale.eti"],
|
||||
Some(committed_content_id),
|
||||
Some(&["keep.eti", "new.eti"]),
|
||||
Some(pending_content_id),
|
||||
)
|
||||
.await;
|
||||
|
||||
recover_incomplete_download(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("landed commit should finalize");
|
||||
|
||||
assert!(root.join("keep.eti").is_file());
|
||||
assert!(root.join("new.eti").is_file());
|
||||
assert!(!root.join("stale.eti").exists());
|
||||
assert!(root.join("notes.txt").is_file());
|
||||
assert_eq!(
|
||||
std::fs::read(root.join(VERSION_INI)).expect("sentinel should be readable"),
|
||||
b"20250101"
|
||||
);
|
||||
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
|
||||
let record = read_valid_record(state.path(), games.path()).await;
|
||||
assert_eq!(record.committed_content_id, Some(pending_content_id));
|
||||
assert_eq!(record.committed_files, ["keep.eti", "new.eti"]);
|
||||
assert!(record.pending_content_id.is_none());
|
||||
assert!(record.pending_files.is_none());
|
||||
assert!(
|
||||
download_ownership_matches_content(
|
||||
games.path(),
|
||||
&["keep.eti", "stale.eti"],
|
||||
Some(&["keep.eti", "new.eti"]),
|
||||
state.path(),
|
||||
"game",
|
||||
pending_content_id,
|
||||
)
|
||||
.await;
|
||||
recover_incomplete_download(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("landed commit should finalize");
|
||||
assert!(root.join("keep.eti").is_file());
|
||||
assert!(root.join("new.eti").is_file());
|
||||
assert!(!root.join("stale.eti").exists());
|
||||
assert!(root.join("notes.txt").is_file());
|
||||
assert_eq!(
|
||||
std::fs::read(root.join(VERSION_INI)).expect("sentinel should be readable"),
|
||||
b"20250101"
|
||||
);
|
||||
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
|
||||
let record = read_valid_record(state.path(), games.path()).await;
|
||||
assert_eq!(record.committed_files, ["keep.eti", "new.eti"]);
|
||||
assert!(record.pending_files.is_none());
|
||||
}
|
||||
.await
|
||||
);
|
||||
assert!(
|
||||
!download_ownership_matches_content(
|
||||
games.path(),
|
||||
state.path(),
|
||||
"game",
|
||||
committed_content_id,
|
||||
)
|
||||
.await
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1230,16 +3515,17 @@ mod tests {
|
||||
schema_version: OWNERSHIP_SCHEMA_VERSION,
|
||||
game_id: "game".to_owned(),
|
||||
games_folder_key: games_folder_key(games.path()),
|
||||
committed_content_id: None,
|
||||
committed_files: Vec::new(),
|
||||
pending_content_id: Some(test_content_id()),
|
||||
pending_files: Some(vec!["archive.eti".to_owned()]),
|
||||
};
|
||||
let record_path = download_ownership_path(state.path(), "game");
|
||||
let tmp_path = download_ownership_tmp_path(state.path(), "game");
|
||||
let record_path = ownership_record_path(state.path(), games.path());
|
||||
let tmp_path = ownership_tmp_path(state.path(), games.path());
|
||||
|
||||
let publication = write_record_with_parent_sync(&record_path, &tmp_path, &record, |_| {
|
||||
Err(std::io::Error::other("injected parent sync failure"))
|
||||
})
|
||||
.await
|
||||
.expect("post-publication sync failure must remain phase-aware");
|
||||
|
||||
assert!(matches!(
|
||||
@@ -1249,6 +3535,269 @@ mod tests {
|
||||
assert_eq!(read_valid_record(state.path(), games.path()).await, record);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn aborted_finalize_waits_for_the_atomic_publication_scope() {
|
||||
use std::{
|
||||
sync::{Arc, Condvar, Mutex, mpsc},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
let games = TempDir::new("lanspread-ownership-scoped-finalize-games");
|
||||
let state = TempDir::new("lanspread-ownership-scoped-finalize-state");
|
||||
let manifest = manifest(games.path(), &["archive.eti"]);
|
||||
let game_root = confined_root(&manifest);
|
||||
let transaction =
|
||||
DownloadOwnershipTransaction::prepare(state.path(), &manifest, &game_root)
|
||||
.await
|
||||
.expect("transaction should prepare");
|
||||
let marker_path = ownership_marker_path(state.path(), games.path());
|
||||
|
||||
let gate = Arc::new((Mutex::new(false), Condvar::new()));
|
||||
let gate_for_publication = Arc::clone(&gate);
|
||||
let gate_for_release = Arc::clone(&gate);
|
||||
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
|
||||
let (request_release, release_requested) = mpsc::channel();
|
||||
let release_thread = thread::spawn(move || {
|
||||
let _ = release_requested.recv_timeout(Duration::from_secs(2));
|
||||
let (gate_open, wake) = &*gate_for_release;
|
||||
let mut gate_open = gate_open.lock().expect("release gate must not be poisoned");
|
||||
*gate_open = true;
|
||||
wake.notify_one();
|
||||
});
|
||||
|
||||
let mut finalize_task = tokio::spawn(async move {
|
||||
transaction
|
||||
.finalize_with_parent_sync(move |_| {
|
||||
entered_tx
|
||||
.send(())
|
||||
.expect("publication must report reaching its sync point");
|
||||
let (gate_open, wake) = &*gate_for_publication;
|
||||
let gate_open = gate_open
|
||||
.lock()
|
||||
.expect("publication gate must not be poisoned");
|
||||
let _gate_open = wake
|
||||
.wait_while(gate_open, |gate_open| !*gate_open)
|
||||
.expect("publication gate must not be poisoned");
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(2), entered_rx)
|
||||
.await
|
||||
.expect("publication must reach the injected sync point")
|
||||
.expect("publication must retain its entry sender");
|
||||
assert!(
|
||||
marker_path.is_file(),
|
||||
"finalization must publish quarantine first"
|
||||
);
|
||||
|
||||
finalize_task.abort();
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(50), &mut finalize_task)
|
||||
.await
|
||||
.is_err(),
|
||||
"task abort must wait for the in-progress publication scope"
|
||||
);
|
||||
|
||||
request_release
|
||||
.send(())
|
||||
.expect("release thread must remain available");
|
||||
release_thread
|
||||
.join()
|
||||
.expect("release thread must not panic");
|
||||
let completion = tokio::time::timeout(Duration::from_secs(2), &mut finalize_task)
|
||||
.await
|
||||
.expect("finalization must stop after its publication scope completes");
|
||||
match completion {
|
||||
Ok(Ok(OwnershipJournalPublication::Durable)) => {}
|
||||
Ok(Ok(OwnershipJournalPublication::NeedsRecovery(error))) => {
|
||||
panic!("publication unexpectedly required recovery: {error}")
|
||||
}
|
||||
Ok(Err(error)) => panic!("publication failed unexpectedly: {error}"),
|
||||
Err(error) if error.is_cancelled() => {}
|
||||
Err(error) => panic!("finalization task failed unexpectedly: {error}"),
|
||||
}
|
||||
|
||||
assert!(
|
||||
!marker_path.exists(),
|
||||
"the atomic publication scope must clear quarantine before task completion"
|
||||
);
|
||||
let record = read_valid_record(state.path(), games.path()).await;
|
||||
assert_eq!(record.committed_files, ["archive.eti"]);
|
||||
assert!(record.pending_files.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_publication_never_allows_mutation_without_a_quarantine_marker() {
|
||||
let games = TempDir::new("lanspread-ownership-marker-failure-games");
|
||||
let state = TempDir::new("lanspread-ownership-marker-failure-state");
|
||||
let manifest = manifest(games.path(), &["archive.eti"]);
|
||||
let game_root = confined_root(&manifest);
|
||||
let transaction =
|
||||
DownloadOwnershipTransaction::prepare(state.path(), &manifest, &game_root)
|
||||
.await
|
||||
.expect("transaction should prepare");
|
||||
let marker_path = ownership_marker_path(state.path(), games.path());
|
||||
std::fs::create_dir(&marker_path).expect("invalid marker entry should be created");
|
||||
|
||||
let publication = transaction
|
||||
.journal_pending()
|
||||
.await
|
||||
.expect("durable pending state should retain phase information");
|
||||
|
||||
assert!(matches!(
|
||||
publication,
|
||||
OwnershipJournalPublication::NeedsRecovery(_)
|
||||
));
|
||||
let record = read_valid_record(state.path(), games.path()).await;
|
||||
assert_eq!(record.pending_files, Some(vec!["archive.eti".to_owned()]));
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::RecoveryRequired
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uncertain_final_record_stays_quarantined_until_recovery_republishes_it() {
|
||||
let games = TempDir::new("lanspread-ownership-finalize-recovery-games");
|
||||
let state = TempDir::new("lanspread-ownership-finalize-recovery-state");
|
||||
let manifest = manifest(games.path(), &["archive.eti"]);
|
||||
let expected_content_id = manifest.catalog_manifest().content_id();
|
||||
write_file(&games.game_root().join(VERSION_INI), b"20250101");
|
||||
let game_root = confined_root(&manifest);
|
||||
let transaction =
|
||||
DownloadOwnershipTransaction::prepare(state.path(), &manifest, &game_root)
|
||||
.await
|
||||
.expect("transaction should prepare");
|
||||
assert!(matches!(
|
||||
transaction
|
||||
.journal_pending()
|
||||
.await
|
||||
.expect("pending ownership should publish"),
|
||||
OwnershipJournalPublication::Durable
|
||||
));
|
||||
|
||||
let publication = transaction
|
||||
.finalize_with_parent_sync(|_| {
|
||||
Err(std::io::Error::other("injected final-record sync failure"))
|
||||
})
|
||||
.await
|
||||
.expect("post-rename uncertainty should stay phase-aware");
|
||||
|
||||
assert!(matches!(
|
||||
publication,
|
||||
OwnershipJournalPublication::NeedsRecovery(_)
|
||||
));
|
||||
let marker_path = ownership_marker_path(state.path(), games.path());
|
||||
assert!(marker_path.is_file());
|
||||
let visible_record = read_valid_record(state.path(), games.path()).await;
|
||||
assert_eq!(
|
||||
visible_record.committed_content_id,
|
||||
Some(expected_content_id)
|
||||
);
|
||||
assert_eq!(visible_record.committed_files, ["archive.eti"]);
|
||||
assert!(visible_record.pending_content_id.is_none());
|
||||
assert!(visible_record.pending_files.is_none());
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::RecoveryRequired
|
||||
);
|
||||
assert!(
|
||||
!download_ownership_matches_content(
|
||||
games.path(),
|
||||
state.path(),
|
||||
"game",
|
||||
expected_content_id,
|
||||
)
|
||||
.await
|
||||
);
|
||||
|
||||
recover_incomplete_download(&games.game_root(), state.path(), "game")
|
||||
.await
|
||||
.expect("recovery should re-publish visible settled ownership");
|
||||
|
||||
assert!(!marker_path.exists());
|
||||
assert_eq!(
|
||||
readiness(games.path(), state.path()).await,
|
||||
DownloadOwnershipReadiness::Settled
|
||||
);
|
||||
assert_eq!(
|
||||
read_valid_record(state.path(), games.path()).await,
|
||||
visible_record
|
||||
);
|
||||
assert!(
|
||||
download_ownership_matches_content(
|
||||
games.path(),
|
||||
state.path(),
|
||||
"game",
|
||||
expected_content_id,
|
||||
)
|
||||
.await
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marker_clear_reports_only_visible_quarantine_failures() {
|
||||
let games = TempDir::new("lanspread-ownership-marker-clear-games");
|
||||
let state = TempDir::new("lanspread-ownership-marker-clear-state");
|
||||
let marker_path = ownership_marker_path(state.path(), games.path());
|
||||
create_recovery_marker(&marker_path).expect("marker should publish");
|
||||
|
||||
clear_recovery_marker_with_parent_sync(&marker_path, |_| {
|
||||
Err(std::io::Error::other(
|
||||
"injected marker removal sync failure",
|
||||
))
|
||||
})
|
||||
.expect("an unlinked marker is visibly settled despite conservative crash uncertainty");
|
||||
assert!(!marker_path.exists());
|
||||
|
||||
std::fs::create_dir(&marker_path).expect("invalid marker entry should be created");
|
||||
assert!(clear_recovery_marker(&marker_path).is_err());
|
||||
assert!(marker_path.is_dir());
|
||||
|
||||
std::fs::remove_dir(&marker_path).expect("invalid marker should be removed");
|
||||
assert!(
|
||||
create_recovery_marker_with_parent_sync(&marker_path, |_| {
|
||||
Err(std::io::Error::other("injected marker sync failure"))
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
marker_path.is_file(),
|
||||
"a visible but uncertain marker must remain conservative"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn marker_creation_rejects_links_and_non_regular_entries_without_mutation() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let games = TempDir::new("lanspread-ownership-marker-nofollow-games");
|
||||
let state = TempDir::new("lanspread-ownership-marker-nofollow-state");
|
||||
let outside = TempDir::new("lanspread-ownership-marker-nofollow-outside");
|
||||
let marker_path = ownership_marker_path(state.path(), games.path());
|
||||
std::fs::create_dir_all(marker_path.parent().expect("marker should have a parent"))
|
||||
.expect("marker parent should be created");
|
||||
let canary_path = outside.path().join("canary");
|
||||
write_file(&canary_path, b"outside");
|
||||
symlink(&canary_path, &marker_path).expect("marker symlink should be created");
|
||||
|
||||
assert!(create_recovery_marker(&marker_path).is_err());
|
||||
assert!(marker_path.is_symlink());
|
||||
assert_eq!(
|
||||
std::fs::read(&canary_path).expect("outside canary should remain readable"),
|
||||
b"outside"
|
||||
);
|
||||
|
||||
std::fs::remove_file(&marker_path).expect("marker symlink should be removed");
|
||||
std::fs::create_dir(&marker_path).expect("non-regular marker should be created");
|
||||
assert!(create_recovery_marker(&marker_path).is_err());
|
||||
assert!(marker_path.is_dir());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_generation_portable_alias_is_rejected_before_payload_mutation() {
|
||||
let games = TempDir::new("lanspread-ownership-alias-games");
|
||||
@@ -1259,7 +3808,7 @@ mod tests {
|
||||
seed_record(state.path(), games.path(), &["Archive.eti"], None).await;
|
||||
|
||||
let manifest = manifest(games.path(), &["archive.eti"]);
|
||||
let confined_root = confined_root(&manifest).await;
|
||||
let confined_root = confined_root(&manifest);
|
||||
let error = DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root)
|
||||
.await
|
||||
.expect_err("case-only ownership changes must fail closed");
|
||||
@@ -1288,9 +3837,10 @@ mod tests {
|
||||
)
|
||||
.await;
|
||||
|
||||
recover_incomplete_download(&root, state.path(), "game")
|
||||
let error = recover_incomplete_download(&root, state.path(), "game")
|
||||
.await
|
||||
.expect("invalid ownership must fail closed");
|
||||
.expect_err("invalid ownership must fail closed");
|
||||
assert!(error.to_string().contains("invalid record"));
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("archive.eti")).expect("payload must be preserved"),
|
||||
@@ -1313,7 +3863,7 @@ mod tests {
|
||||
write_file(&root.join("archive.eti"), b"replacement");
|
||||
|
||||
let manifest = manifest(games.path(), &["archive.eti"]);
|
||||
let confined_root = confined_root(&manifest).await;
|
||||
let confined_root = confined_root(&manifest);
|
||||
DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root)
|
||||
.await
|
||||
.expect("path ownership deliberately survives local root replacement");
|
||||
@@ -1353,11 +3903,15 @@ mod tests {
|
||||
);
|
||||
assert!(!games_b.game_root().join(VERSION_INI).exists());
|
||||
|
||||
write_file(&download_ownership_path(state.path(), "game"), b"corrupt");
|
||||
write_file(
|
||||
&ownership_record_path(state.path(), games_b.path()),
|
||||
b"corrupt",
|
||||
);
|
||||
write_file(&games_b.game_root().join("partial.eti"), b"unknown");
|
||||
recover_incomplete_download(&games_b.game_root(), state.path(), "game")
|
||||
let error = recover_incomplete_download(&games_b.game_root(), state.path(), "game")
|
||||
.await
|
||||
.expect("corrupt state should preserve unknown payload");
|
||||
.expect_err("corrupt state should fail closed");
|
||||
assert!(error.to_string().contains("invalid record"));
|
||||
assert_eq!(
|
||||
std::fs::read(games_b.game_root().join("partial.eti"))
|
||||
.expect("unknown payload should remain readable"),
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
use std::{collections::HashMap, net::SocketAddr};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::manifest::{ValidatedDownloadEntry, ValidatedDownloadPath};
|
||||
use crate::config::CHUNK_SIZE;
|
||||
use lanspread_db::content_manifest::{Blake3Digest, CanonicalCatalogPath, ContentId};
|
||||
use lanspread_proto::PeerEndpoint;
|
||||
|
||||
use super::{
|
||||
manifest::{ExpectedCatalogBlake3, ValidatedDownloadManifest, ValidatedDownloadPath},
|
||||
transfer_error::DownloadTransferResult,
|
||||
};
|
||||
use crate::game_paths::VERSION_INI;
|
||||
|
||||
/// Represents a chunk of a file to be downloaded.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct DownloadChunk {
|
||||
pub(super) request_path: String,
|
||||
pub(super) content_id: ContentId,
|
||||
pub(super) destination: ValidatedDownloadPath,
|
||||
pub(super) offset: u64,
|
||||
pub(super) length: u64,
|
||||
pub(super) retry_count: usize,
|
||||
pub(super) last_peer: Option<SocketAddr>,
|
||||
pub(super) expected_blake3: ExpectedCatalogBlake3,
|
||||
}
|
||||
|
||||
impl DownloadChunk {
|
||||
pub(super) fn expected_blake3(&self) -> Blake3Digest {
|
||||
self.expected_blake3.digest()
|
||||
}
|
||||
|
||||
pub(super) fn is_version_ini(&self) -> bool {
|
||||
self.destination.canonical() == VERSION_INI
|
||||
}
|
||||
|
||||
pub(super) const fn canonical_path(&self) -> &CanonicalCatalogPath {
|
||||
self.destination.catalog_path()
|
||||
}
|
||||
}
|
||||
|
||||
/// Download plan for a single peer.
|
||||
@@ -24,85 +43,132 @@ pub(super) struct PeerDownloadPlan {
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ChunkDownloadResult {
|
||||
pub(super) chunk: DownloadChunk,
|
||||
pub(super) result: eyre::Result<()>,
|
||||
pub(super) peer_addr: SocketAddr,
|
||||
pub(super) result: DownloadTransferResult<()>,
|
||||
pub(super) peer_endpoint: PeerEndpoint,
|
||||
}
|
||||
|
||||
/// Resolves which peers have a specific file.
|
||||
pub(super) fn resolve_file_peers<'a>(
|
||||
relative_path: &str,
|
||||
file_peer_map: &'a HashMap<String, Vec<SocketAddr>>,
|
||||
fallback: &'a [SocketAddr],
|
||||
) -> &'a [SocketAddr] {
|
||||
if let Some(peers) = file_peer_map.get(relative_path)
|
||||
&& !peers.is_empty()
|
||||
{
|
||||
return peers;
|
||||
#[derive(Debug, Eq, Hash, PartialEq)]
|
||||
struct DownloadChunkKey {
|
||||
content_id: ContentId,
|
||||
canonical_path: CanonicalCatalogPath,
|
||||
offset: u64,
|
||||
length: u64,
|
||||
}
|
||||
|
||||
impl From<&DownloadChunk> for DownloadChunkKey {
|
||||
fn from(chunk: &DownloadChunk) -> Self {
|
||||
Self {
|
||||
content_id: chunk.content_id,
|
||||
canonical_path: chunk.canonical_path().clone(),
|
||||
offset: chunk.offset,
|
||||
length: chunk.length,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconciles one transport response against the exact planned chunk-key set.
|
||||
///
|
||||
/// Returning successfully proves that every planned key has exactly one result
|
||||
/// and that the transport did not introduce an unplanned key.
|
||||
pub(super) fn reconcile_chunk_results<T, F>(
|
||||
planned: Vec<T>,
|
||||
results: Vec<ChunkDownloadResult>,
|
||||
expected_endpoint: PeerEndpoint,
|
||||
chunk_for: F,
|
||||
phase: &str,
|
||||
) -> eyre::Result<Vec<(T, ChunkDownloadResult)>>
|
||||
where
|
||||
F: for<'a> Fn(&'a T) -> &'a DownloadChunk,
|
||||
{
|
||||
let mut planned_by_key = HashMap::with_capacity(planned.len());
|
||||
for planned_item in planned {
|
||||
let key = DownloadChunkKey::from(chunk_for(&planned_item));
|
||||
if planned_by_key.insert(key, planned_item).is_some() {
|
||||
eyre::bail!("{phase} plan contains a duplicate chunk key");
|
||||
}
|
||||
}
|
||||
|
||||
fallback
|
||||
let mut reconciled = Vec::with_capacity(results.len());
|
||||
for result in results {
|
||||
if result.peer_endpoint != expected_endpoint {
|
||||
eyre::bail!(
|
||||
"{phase} transport attributed a chunk result to unexpected endpoint {} at {} instead of {} at {}",
|
||||
result.peer_endpoint.peer_id,
|
||||
result.peer_endpoint.addr,
|
||||
expected_endpoint.peer_id,
|
||||
expected_endpoint.addr,
|
||||
);
|
||||
}
|
||||
let key = DownloadChunkKey::from(&result.chunk);
|
||||
let planned_item = planned_by_key.remove(&key).ok_or_else(|| {
|
||||
eyre::eyre!("{phase} transport returned an unplanned or duplicate chunk result")
|
||||
})?;
|
||||
reconciled.push((planned_item, result));
|
||||
}
|
||||
|
||||
if !planned_by_key.is_empty() {
|
||||
eyre::bail!("{phase} transport omitted one or more planned chunk results");
|
||||
}
|
||||
Ok(reconciled)
|
||||
}
|
||||
|
||||
/// Builds download plans distributing files across peers.
|
||||
/// Builds a catalog-owned plan across the caller's eligible source set.
|
||||
pub(super) fn build_peer_plans(
|
||||
peers: &[SocketAddr],
|
||||
file_descs: &[ValidatedDownloadEntry],
|
||||
file_peer_map: &HashMap<String, Vec<SocketAddr>>,
|
||||
) -> HashMap<SocketAddr, PeerDownloadPlan> {
|
||||
let mut plans: HashMap<SocketAddr, PeerDownloadPlan> = HashMap::new();
|
||||
peers: &[PeerEndpoint],
|
||||
manifest: &ValidatedDownloadManifest,
|
||||
) -> eyre::Result<HashMap<PeerEndpoint, PeerDownloadPlan>> {
|
||||
let mut plans: HashMap<PeerEndpoint, PeerDownloadPlan> = HashMap::new();
|
||||
let chunk_size = manifest.catalog_manifest().chunk_size();
|
||||
let content_id = manifest.content_id();
|
||||
if peers.is_empty() {
|
||||
return plans;
|
||||
return Ok(plans);
|
||||
}
|
||||
|
||||
let mut planned_bytes: HashMap<SocketAddr, u64> = HashMap::new();
|
||||
let mut planned_bytes: HashMap<PeerEndpoint, u64> = HashMap::new();
|
||||
let mut tie_breaker = 0usize;
|
||||
|
||||
for desc in file_descs.iter().filter(|entry| !entry.is_dir()) {
|
||||
for desc in manifest.entries().iter().filter(|entry| !entry.is_dir()) {
|
||||
let size = desc.size();
|
||||
let eligible_peers = resolve_file_peers(desc.protocol_path(), file_peer_map, peers);
|
||||
if eligible_peers.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if size == 0 {
|
||||
let peer = select_least_loaded_peer(eligible_peers, &planned_bytes, &mut tie_breaker);
|
||||
let peer = select_least_loaded_peer(peers, &planned_bytes, &mut tie_breaker);
|
||||
*planned_bytes.entry(peer).or_default() += 1;
|
||||
plans.entry(peer).or_default().chunks.push(DownloadChunk {
|
||||
request_path: desc.protocol_path().to_owned(),
|
||||
content_id,
|
||||
destination: desc.destination().clone(),
|
||||
offset: 0,
|
||||
length: 0,
|
||||
retry_count: 0,
|
||||
last_peer: Some(peer),
|
||||
expected_blake3: manifest.expected_blake3(desc, None)?,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut offset = 0u64;
|
||||
let mut chunk_index = 0usize;
|
||||
while offset < size {
|
||||
let length = std::cmp::min(CHUNK_SIZE, size - offset);
|
||||
let peer = select_least_loaded_peer(eligible_peers, &planned_bytes, &mut tie_breaker);
|
||||
let length = std::cmp::min(chunk_size, size - offset);
|
||||
let peer = select_least_loaded_peer(peers, &planned_bytes, &mut tie_breaker);
|
||||
*planned_bytes.entry(peer).or_default() += length;
|
||||
plans.entry(peer).or_default().chunks.push(DownloadChunk {
|
||||
request_path: desc.protocol_path().to_owned(),
|
||||
content_id,
|
||||
destination: desc.destination().clone(),
|
||||
offset,
|
||||
length,
|
||||
retry_count: 0,
|
||||
last_peer: Some(peer),
|
||||
expected_blake3: manifest.expected_blake3(desc, Some(chunk_index))?,
|
||||
});
|
||||
offset += length;
|
||||
chunk_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
plans
|
||||
Ok(plans)
|
||||
}
|
||||
|
||||
fn select_least_loaded_peer(
|
||||
eligible_peers: &[SocketAddr],
|
||||
planned_bytes: &HashMap<SocketAddr, u64>,
|
||||
eligible_peers: &[PeerEndpoint],
|
||||
planned_bytes: &HashMap<PeerEndpoint, u64>,
|
||||
tie_breaker: &mut usize,
|
||||
) -> SocketAddr {
|
||||
) -> PeerEndpoint {
|
||||
let start = *tie_breaker % eligible_peers.len();
|
||||
*tie_breaker = (*tie_breaker).wrapping_add(1);
|
||||
|
||||
@@ -123,60 +189,128 @@ fn select_least_loaded_peer(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
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)
|
||||
use lanspread_db::content_manifest::{
|
||||
Blake3Digest,
|
||||
CATALOG_CHUNK_SIZE,
|
||||
CatalogContentManifest,
|
||||
CatalogContentManifestBody,
|
||||
CatalogFileEntry,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::TempDir;
|
||||
|
||||
fn catalog_file(
|
||||
canonical_path: &str,
|
||||
size: u64,
|
||||
chunk_blake3: Vec<Blake3Digest>,
|
||||
) -> CatalogFileEntry {
|
||||
let file_blake3 = match chunk_blake3.as_slice() {
|
||||
[] => Blake3Digest::hash(&[]),
|
||||
[only] => *only,
|
||||
_ => Blake3Digest::from_bytes([0xf0; 32]),
|
||||
};
|
||||
CatalogFileEntry::file(canonical_path, size, file_blake3, chunk_blake3)
|
||||
.expect("catalog test file should validate")
|
||||
}
|
||||
|
||||
fn loopback_addr(port: u16) -> SocketAddr {
|
||||
SocketAddr::from(([127, 0, 0, 1], port))
|
||||
fn validated_manifest(
|
||||
mut files: Vec<CatalogFileEntry>,
|
||||
) -> (TempDir, ValidatedDownloadManifest) {
|
||||
let version_digest = Blake3Digest::hash(b"1");
|
||||
files.push(
|
||||
CatalogFileEntry::file("version.ini", 1, version_digest, vec![version_digest])
|
||||
.expect("version.ini should validate"),
|
||||
);
|
||||
files.sort_by(|left, right| {
|
||||
left.canonical_path()
|
||||
.as_str()
|
||||
.cmp(right.canonical_path().as_str())
|
||||
});
|
||||
let catalog = Arc::new(
|
||||
CatalogContentManifest::seal(
|
||||
CatalogContentManifestBody::new("game", "1", files, Vec::new())
|
||||
.expect("catalog body should validate"),
|
||||
)
|
||||
.expect("catalog manifest should seal"),
|
||||
);
|
||||
let temp = TempDir::new("lanspread-planning");
|
||||
let manifest = ValidatedDownloadManifest::from_catalog(temp.path(), catalog)
|
||||
.expect("catalog download manifest should validate");
|
||||
(temp, manifest)
|
||||
}
|
||||
|
||||
fn peer_endpoint(port: u16) -> PeerEndpoint {
|
||||
PeerEndpoint::new(
|
||||
lanspread_proto::PeerId::from_bytes(*blake3::hash(&port.to_le_bytes()).as_bytes()),
|
||||
SocketAddr::from(([127, 0, 0, 1], port)),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_peer_plans_handles_partial_final_chunk() {
|
||||
let peers = vec![loopback_addr(12000), loopback_addr(12001)];
|
||||
let file_size = CHUNK_SIZE * 2 + CHUNK_SIZE / 4;
|
||||
let mut file_peer_map = HashMap::new();
|
||||
file_peer_map.insert("game/file.dat".to_string(), peers.clone());
|
||||
let file_descs = vec![file("game/file.dat", file_size)];
|
||||
let peers = vec![peer_endpoint(12000), peer_endpoint(12001)];
|
||||
let file_size = CATALOG_CHUNK_SIZE * 2 + CATALOG_CHUNK_SIZE / 4;
|
||||
let expected = [
|
||||
Blake3Digest::from_bytes([1; 32]),
|
||||
Blake3Digest::from_bytes([2; 32]),
|
||||
Blake3Digest::from_bytes([3; 32]),
|
||||
];
|
||||
let (_temp, manifest) =
|
||||
validated_manifest(vec![catalog_file("file.dat", file_size, expected.to_vec())]);
|
||||
|
||||
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 plans = build_peer_plans(&peers, &manifest).expect("catalog plan should build");
|
||||
let mut chunks: Vec<_> = plans
|
||||
.values()
|
||||
.flat_map(|plan| plan.chunks.iter())
|
||||
.filter(|chunk| chunk.canonical_path().as_str() == "file.dat")
|
||||
.collect();
|
||||
|
||||
assert_eq!(chunks.len(), 3, "expected three chunks for 2.25 blocks");
|
||||
|
||||
chunks.sort_by_key(|chunk| chunk.offset);
|
||||
let last_chunk = chunks.last().expect("last chunk exists");
|
||||
|
||||
assert_eq!(last_chunk.offset, CHUNK_SIZE * 2);
|
||||
assert_eq!(last_chunk.offset, CATALOG_CHUNK_SIZE * 2);
|
||||
assert_eq!(last_chunk.length, file_size - last_chunk.offset);
|
||||
assert_eq!(last_chunk.length, CHUNK_SIZE / 4);
|
||||
assert_eq!(last_chunk.length, CATALOG_CHUNK_SIZE / 4);
|
||||
assert_eq!(
|
||||
last_chunk.offset + last_chunk.length,
|
||||
file_size,
|
||||
"last chunk should finish the file"
|
||||
);
|
||||
assert_eq!(
|
||||
chunks
|
||||
.iter()
|
||||
.map(|chunk| chunk.expected_blake3())
|
||||
.collect::<Vec<_>>(),
|
||||
expected,
|
||||
"each chunk should resolve the digest at its retained catalog index"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_peer_plans_spreads_large_file_chunks_across_shared_peers() {
|
||||
let peers = vec![loopback_addr(12000), loopback_addr(12001)];
|
||||
let large_file = "game/large.eti";
|
||||
let file_size = CHUNK_SIZE * 3 + CHUNK_SIZE / 2;
|
||||
let mut file_peer_map = HashMap::new();
|
||||
file_peer_map.insert("game/version.ini".to_string(), peers.clone());
|
||||
file_peer_map.insert(large_file.to_string(), peers.clone());
|
||||
let file_descs = vec![file("game/version.ini", 9), file(large_file, file_size)];
|
||||
let peers = vec![peer_endpoint(12000), peer_endpoint(12001)];
|
||||
let large_file = "large.eti";
|
||||
let file_size = CATALOG_CHUNK_SIZE * 3 + CATALOG_CHUNK_SIZE / 2;
|
||||
let (_temp, manifest) = validated_manifest(vec![catalog_file(
|
||||
"large.eti",
|
||||
file_size,
|
||||
(1_u8..=4)
|
||||
.map(|seed| Blake3Digest::from_bytes([seed; 32]))
|
||||
.collect(),
|
||||
)]);
|
||||
|
||||
let plans = build_peer_plans(&peers, &file_descs, &file_peer_map);
|
||||
let plans = build_peer_plans(&peers, &manifest).expect("catalog plan should build");
|
||||
let mut chunk_counts = HashMap::new();
|
||||
let mut byte_counts = HashMap::new();
|
||||
|
||||
for (peer, plan) in plans {
|
||||
for chunk in plan.chunks {
|
||||
if chunk.request_path == large_file {
|
||||
if chunk.canonical_path().as_str() == large_file {
|
||||
*chunk_counts.entry(peer).or_insert(0usize) += 1;
|
||||
*byte_counts.entry(peer).or_insert(0u64) += chunk.length;
|
||||
}
|
||||
@@ -192,7 +326,7 @@ mod tests {
|
||||
];
|
||||
assert_eq!(assigned_bytes.iter().sum::<u64>(), file_size);
|
||||
assert!(
|
||||
assigned_bytes[0].abs_diff(assigned_bytes[1]) <= CHUNK_SIZE,
|
||||
assigned_bytes[0].abs_diff(assigned_bytes[1]) <= CATALOG_CHUNK_SIZE,
|
||||
"large file bytes should be balanced within one chunk: {} vs {}",
|
||||
assigned_bytes[0],
|
||||
assigned_bytes[1]
|
||||
@@ -200,47 +334,172 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_peer_plans_respects_file_peer_map() {
|
||||
let shared_a = loopback_addr(12010);
|
||||
let shared_b = loopback_addr(12011);
|
||||
let exclusive = loopback_addr(12012);
|
||||
let peers = vec![shared_a, shared_b, exclusive];
|
||||
|
||||
let mut file_peer_map = HashMap::new();
|
||||
file_peer_map.insert("shared.bin".to_string(), vec![shared_a, shared_b]);
|
||||
file_peer_map.insert("exclusive.bin".to_string(), vec![exclusive]);
|
||||
|
||||
let file_descs = vec![
|
||||
file("shared.bin", CHUNK_SIZE * 2),
|
||||
file("exclusive.bin", CHUNK_SIZE),
|
||||
fn build_peer_plans_uses_the_complete_exact_content_source_set() {
|
||||
let peers = vec![
|
||||
peer_endpoint(12010),
|
||||
peer_endpoint(12011),
|
||||
peer_endpoint(12012),
|
||||
];
|
||||
|
||||
let plans = build_peer_plans(&peers, &file_descs, &file_peer_map);
|
||||
let exclusive_plan = plans
|
||||
.get(&exclusive)
|
||||
.expect("exclusive peer should have a plan");
|
||||
assert!(
|
||||
exclusive_plan
|
||||
.chunks
|
||||
.iter()
|
||||
.all(|chunk| chunk.request_path == "exclusive.bin"),
|
||||
"exclusive peer should only receive exclusive.bin chunks"
|
||||
);
|
||||
let (_temp, manifest) = validated_manifest(vec![
|
||||
catalog_file(
|
||||
"first.bin",
|
||||
CATALOG_CHUNK_SIZE,
|
||||
vec![Blake3Digest::from_bytes([1; 32])],
|
||||
),
|
||||
catalog_file(
|
||||
"second.bin",
|
||||
CATALOG_CHUNK_SIZE * 2,
|
||||
vec![
|
||||
Blake3Digest::from_bytes([2; 32]),
|
||||
Blake3Digest::from_bytes([3; 32]),
|
||||
],
|
||||
),
|
||||
]);
|
||||
|
||||
for (peer, plan) in plans {
|
||||
for chunk in plan.chunks {
|
||||
match chunk.request_path.as_str() {
|
||||
"exclusive.bin" => assert_eq!(
|
||||
peer, exclusive,
|
||||
"exclusive.bin chunks should only be assigned to the exclusive peer"
|
||||
),
|
||||
"shared.bin" => assert!(
|
||||
peer == shared_a || peer == shared_b,
|
||||
"shared.bin chunks must stay within shared peers"
|
||||
),
|
||||
let plans = build_peer_plans(&peers, &manifest).expect("catalog plan should build");
|
||||
|
||||
for (peer, plan) in &plans {
|
||||
assert!(peers.contains(peer), "only an eligible source may be used");
|
||||
for chunk in &plan.chunks {
|
||||
match chunk.canonical_path().as_str() {
|
||||
"first.bin" | "second.bin" | "version.ini" => {}
|
||||
other => panic!("unexpected file in plan: {other}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
plans.values().map(|plan| plan.chunks.len()).sum::<usize>(),
|
||||
4,
|
||||
"every catalog file and chunk should be planned exactly once"
|
||||
);
|
||||
assert!(
|
||||
peers.iter().all(|peer| plans.contains_key(peer)),
|
||||
"load balancing should use every exact-content source"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_byte_file_is_planned_with_its_catalog_file_digest() {
|
||||
let peer = peer_endpoint(12020);
|
||||
let empty_digest = Blake3Digest::hash(&[]);
|
||||
let (_temp, manifest) = validated_manifest(vec![catalog_file("empty.bin", 0, Vec::new())]);
|
||||
|
||||
let plans = build_peer_plans(&[peer], &manifest).expect("catalog plan should build");
|
||||
let empty = plans[&peer]
|
||||
.chunks
|
||||
.iter()
|
||||
.find(|chunk| chunk.canonical_path().as_str() == "empty.bin")
|
||||
.expect("empty file should still have one verification chunk");
|
||||
|
||||
assert_eq!(empty.length, 0);
|
||||
assert_eq!(empty.expected_blake3(), empty_digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_digest_references_outlive_the_download_manifest() {
|
||||
let peer = peer_endpoint(12021);
|
||||
let expected = Blake3Digest::from_bytes([9; 32]);
|
||||
let (temp, manifest) =
|
||||
validated_manifest(vec![catalog_file("archive.eti", 1, vec![expected])]);
|
||||
let plans = build_peer_plans(&[peer], &manifest).expect("catalog plan should build");
|
||||
|
||||
drop(manifest);
|
||||
drop(temp);
|
||||
|
||||
let archive = plans[&peer]
|
||||
.chunks
|
||||
.iter()
|
||||
.find(|chunk| chunk.canonical_path().as_str() == "archive.eti")
|
||||
.expect("archive chunk should remain planned");
|
||||
assert_eq!(archive.expected_blake3(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_reconciliation_rejects_an_omitted_initial_chunk() {
|
||||
let peer = peer_endpoint(12023);
|
||||
let (_temp, manifest) = validated_manifest(vec![catalog_file(
|
||||
"archive.eti",
|
||||
1,
|
||||
vec![Blake3Digest::from_bytes([7; 32])],
|
||||
)]);
|
||||
let mut plans = build_peer_plans(&[peer], &manifest).expect("catalog plan should build");
|
||||
let planned = plans.remove(&peer).expect("peer should have a plan").chunks;
|
||||
|
||||
let error =
|
||||
reconcile_chunk_results(planned, Vec::new(), peer, |chunk| chunk, "initial download")
|
||||
.expect_err("an omitted result must fail closed");
|
||||
|
||||
assert!(error.to_string().contains("omitted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_reconciliation_rejects_a_duplicate_initial_chunk() {
|
||||
let peer = peer_endpoint(12024);
|
||||
let (_temp, manifest) = validated_manifest(vec![catalog_file(
|
||||
"archive.eti",
|
||||
1,
|
||||
vec![Blake3Digest::from_bytes([8; 32])],
|
||||
)]);
|
||||
let mut plans = build_peer_plans(&[peer], &manifest).expect("catalog plan should build");
|
||||
let planned = plans.remove(&peer).expect("peer should have a plan").chunks;
|
||||
let duplicate = planned
|
||||
.first()
|
||||
.expect("plan should contain a chunk")
|
||||
.clone();
|
||||
let results = vec![
|
||||
ChunkDownloadResult {
|
||||
chunk: duplicate.clone(),
|
||||
result: Ok(()),
|
||||
peer_endpoint: peer,
|
||||
},
|
||||
ChunkDownloadResult {
|
||||
chunk: duplicate,
|
||||
result: Ok(()),
|
||||
peer_endpoint: peer,
|
||||
},
|
||||
];
|
||||
|
||||
let error =
|
||||
reconcile_chunk_results(planned, results, peer, |chunk| chunk, "initial download")
|
||||
.expect_err("a duplicate result must fail closed");
|
||||
|
||||
assert!(error.to_string().contains("duplicate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_reconciliation_rejects_endpoint_misattribution() {
|
||||
let expected = peer_endpoint(12025);
|
||||
let unexpected = peer_endpoint(12026);
|
||||
let (_temp, manifest) = validated_manifest(vec![catalog_file(
|
||||
"archive.eti",
|
||||
1,
|
||||
vec![Blake3Digest::from_bytes([9; 32])],
|
||||
)]);
|
||||
let mut plans =
|
||||
build_peer_plans(&[expected], &manifest).expect("catalog plan should build");
|
||||
let planned = plans
|
||||
.remove(&expected)
|
||||
.expect("peer should have a plan")
|
||||
.chunks;
|
||||
let result_chunk = planned
|
||||
.first()
|
||||
.expect("plan should contain a chunk")
|
||||
.clone();
|
||||
|
||||
let error = reconcile_chunk_results(
|
||||
planned,
|
||||
vec![ChunkDownloadResult {
|
||||
chunk: result_chunk,
|
||||
result: Ok(()),
|
||||
peer_endpoint: unexpected,
|
||||
}],
|
||||
expected,
|
||||
|chunk| chunk,
|
||||
"initial download",
|
||||
)
|
||||
.expect_err("endpoint mismatch must fail closed");
|
||||
|
||||
assert!(error.to_string().contains("unexpected endpoint"));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
future::Future,
|
||||
net::SocketAddr,
|
||||
sync::{
|
||||
Arc,
|
||||
Mutex,
|
||||
@@ -10,18 +9,18 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tokio::{
|
||||
sync::mpsc::UnboundedSender,
|
||||
time::{self, MissedTickBehavior},
|
||||
};
|
||||
use lanspread_db::content_manifest::{CanonicalCatalogPath, ContentId};
|
||||
use lanspread_proto::{PeerEndpoint, PeerId};
|
||||
use tokio::time::{self, MissedTickBehavior};
|
||||
|
||||
use crate::{DownloadProgress, PeerEvent, events};
|
||||
use crate::{DownloadAttemptKey, DownloadProgress, transfer_status::DownloadAttemptReporter};
|
||||
|
||||
const DOWNLOAD_PROGRESS_UPDATE_INTERVAL: Duration = Duration::from_millis(500);
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
struct ChunkProgressKey {
|
||||
relative_path: String,
|
||||
content_id: ContentId,
|
||||
canonical_path: CanonicalCatalogPath,
|
||||
offset: u64,
|
||||
}
|
||||
|
||||
@@ -30,7 +29,7 @@ pub(super) struct DownloadProgressTracker {
|
||||
downloaded_bytes: AtomicU64,
|
||||
transferred_bytes: AtomicU64,
|
||||
chunks: Mutex<HashMap<ChunkProgressKey, u64>>,
|
||||
active_peers: Mutex<HashMap<SocketAddr, usize>>,
|
||||
active_peers: Mutex<HashMap<PeerId, usize>>,
|
||||
}
|
||||
|
||||
impl DownloadProgressTracker {
|
||||
@@ -46,18 +45,20 @@ impl DownloadProgressTracker {
|
||||
|
||||
pub(super) fn track_chunk(
|
||||
self: &Arc<Self>,
|
||||
peer_addr: SocketAddr,
|
||||
relative_path: &str,
|
||||
peer_endpoint: PeerEndpoint,
|
||||
content_id: ContentId,
|
||||
canonical_path: &CanonicalCatalogPath,
|
||||
offset: u64,
|
||||
expected_bytes: u64,
|
||||
) -> ChunkProgress {
|
||||
ChunkProgress {
|
||||
tracker: self.clone(),
|
||||
key: ChunkProgressKey {
|
||||
relative_path: relative_path.to_string(),
|
||||
content_id,
|
||||
canonical_path: canonical_path.clone(),
|
||||
offset,
|
||||
},
|
||||
_peer_activity: self.track_active_peer(peer_addr),
|
||||
_peer_activity: self.track_active_peer(peer_endpoint.peer_id),
|
||||
expected_bytes,
|
||||
received_bytes: 0,
|
||||
}
|
||||
@@ -109,32 +110,32 @@ impl DownloadProgressTracker {
|
||||
}
|
||||
}
|
||||
|
||||
fn track_active_peer(self: &Arc<Self>, peer_addr: SocketAddr) -> ActivePeerDownload {
|
||||
fn track_active_peer(self: &Arc<Self>, peer_id: PeerId) -> ActivePeerDownload {
|
||||
{
|
||||
let mut active_peers = self
|
||||
.active_peers
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*active_peers.entry(peer_addr).or_default() += 1;
|
||||
*active_peers.entry(peer_id).or_default() += 1;
|
||||
}
|
||||
|
||||
ActivePeerDownload {
|
||||
tracker: self.clone(),
|
||||
peer_addr,
|
||||
peer_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_active_peer(&self, peer_addr: SocketAddr) {
|
||||
fn finish_active_peer(&self, peer_id: PeerId) {
|
||||
let mut active_peers = self
|
||||
.active_peers
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
|
||||
let Some(count) = active_peers.get_mut(&peer_addr) else {
|
||||
let Some(count) = active_peers.get_mut(&peer_id) else {
|
||||
return;
|
||||
};
|
||||
if *count <= 1 {
|
||||
active_peers.remove(&peer_addr);
|
||||
active_peers.remove(&peer_id);
|
||||
} else {
|
||||
*count -= 1;
|
||||
}
|
||||
@@ -147,9 +148,9 @@ impl DownloadProgressTracker {
|
||||
.len()
|
||||
}
|
||||
|
||||
fn snapshot(&self, id: &str, bytes_per_second: u64) -> DownloadProgress {
|
||||
fn snapshot(&self, attempt: &DownloadAttemptKey, bytes_per_second: u64) -> DownloadProgress {
|
||||
DownloadProgress {
|
||||
id: id.to_string(),
|
||||
attempt: attempt.clone(),
|
||||
downloaded_bytes: self.reported_downloaded_bytes(),
|
||||
total_bytes: self.total_bytes,
|
||||
bytes_per_second,
|
||||
@@ -183,12 +184,12 @@ impl ChunkProgress {
|
||||
|
||||
struct ActivePeerDownload {
|
||||
tracker: Arc<DownloadProgressTracker>,
|
||||
peer_addr: SocketAddr,
|
||||
peer_id: PeerId,
|
||||
}
|
||||
|
||||
impl Drop for ActivePeerDownload {
|
||||
fn drop(&mut self) {
|
||||
self.tracker.finish_active_peer(self.peer_addr);
|
||||
self.tracker.finish_active_peer(self.peer_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,23 +200,17 @@ fn add_saturating(counter: &AtomicU64, delta: u64) {
|
||||
}
|
||||
|
||||
struct ProgressSampler {
|
||||
id: String,
|
||||
attempt: DownloadAttemptReporter,
|
||||
tracker: Arc<DownloadProgressTracker>,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
last_bytes: u64,
|
||||
last_at: Instant,
|
||||
}
|
||||
|
||||
impl ProgressSampler {
|
||||
fn new(
|
||||
id: String,
|
||||
tracker: Arc<DownloadProgressTracker>,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
) -> Self {
|
||||
fn new(attempt: DownloadAttemptReporter, tracker: Arc<DownloadProgressTracker>) -> Self {
|
||||
Self {
|
||||
id,
|
||||
attempt,
|
||||
tracker,
|
||||
tx_notify_ui,
|
||||
last_bytes: 0,
|
||||
last_at: Instant::now(),
|
||||
}
|
||||
@@ -241,10 +236,8 @@ impl ProgressSampler {
|
||||
}
|
||||
|
||||
fn emit(&self, bytes_per_second: u64) {
|
||||
events::send(
|
||||
&self.tx_notify_ui,
|
||||
PeerEvent::DownloadGameFilesProgress(self.tracker.snapshot(&self.id, bytes_per_second)),
|
||||
);
|
||||
self.attempt
|
||||
.emit_progress(self.tracker.snapshot(self.attempt.key(), bytes_per_second));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,15 +248,14 @@ fn bytes_per_second(bytes: u64, elapsed: Duration) -> u64 {
|
||||
}
|
||||
|
||||
pub(super) async fn sample_download_progress<F, T>(
|
||||
id: &str,
|
||||
attempt: DownloadAttemptReporter,
|
||||
tracker: Arc<DownloadProgressTracker>,
|
||||
tx_notify_ui: UnboundedSender<PeerEvent>,
|
||||
future: F,
|
||||
) -> T
|
||||
where
|
||||
F: Future<Output = T>,
|
||||
{
|
||||
let mut sampler = ProgressSampler::new(id.to_string(), tracker, tx_notify_ui);
|
||||
let mut sampler = ProgressSampler::new(attempt, tracker);
|
||||
sampler.emit_initial();
|
||||
|
||||
let mut interval = time::interval(DOWNLOAD_PROGRESS_UPDATE_INTERVAL);
|
||||
@@ -284,21 +276,32 @@ where
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn loopback_addr(port: u16) -> SocketAddr {
|
||||
SocketAddr::from(([127, 0, 0, 1], port))
|
||||
fn endpoint(seed: u8, port: u16) -> PeerEndpoint {
|
||||
PeerEndpoint::new(
|
||||
PeerId::from_bytes([seed; 32]),
|
||||
SocketAddr::from(([127, 0, 0, 1], port)),
|
||||
)
|
||||
}
|
||||
|
||||
fn path(value: &str) -> CanonicalCatalogPath {
|
||||
CanonicalCatalogPath::new(value).expect("test path should be canonical")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_counts_only_new_bytes_for_a_retried_chunk() {
|
||||
let tracker = DownloadProgressTracker::new(100);
|
||||
let peer = loopback_addr(12000);
|
||||
let mut first_attempt = tracker.track_chunk(peer, "game/file.bin", 0, 100);
|
||||
let peer = endpoint(1, 12000);
|
||||
let content_id = ContentId::from_bytes([2; 32]);
|
||||
let path = path("file.bin");
|
||||
let mut first_attempt = tracker.track_chunk(peer, content_id, &path, 0, 100);
|
||||
first_attempt.record_bytes(40);
|
||||
first_attempt.record_bytes(10);
|
||||
|
||||
let mut retry = tracker.track_chunk(peer, "game/file.bin", 0, 100);
|
||||
let mut retry = tracker.track_chunk(peer, content_id, &path, 0, 100);
|
||||
retry.record_bytes(25);
|
||||
retry.record_bytes(50);
|
||||
|
||||
@@ -306,10 +309,38 @@ mod tests {
|
||||
assert_eq!(tracker.raw_transferred_bytes(), 125);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_keys_progress_by_exact_content_and_canonical_path() {
|
||||
let tracker = DownloadProgressTracker::new(100);
|
||||
let peer = endpoint(1, 12000);
|
||||
let first_content = ContentId::from_bytes([2; 32]);
|
||||
let second_content = ContentId::from_bytes([3; 32]);
|
||||
let first_path = path("first.bin");
|
||||
let second_path = path("second.bin");
|
||||
|
||||
tracker
|
||||
.track_chunk(peer, first_content, &first_path, 0, 100)
|
||||
.record_bytes(10);
|
||||
tracker
|
||||
.track_chunk(peer, second_content, &first_path, 0, 100)
|
||||
.record_bytes(20);
|
||||
tracker
|
||||
.track_chunk(peer, first_content, &second_path, 0, 100)
|
||||
.record_bytes(30);
|
||||
|
||||
assert_eq!(tracker.reported_downloaded_bytes(), 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_clamps_reported_bytes_to_total() {
|
||||
let tracker = DownloadProgressTracker::new(10);
|
||||
let mut chunk = tracker.track_chunk(loopback_addr(12000), "game/file.bin", 0, 0);
|
||||
let mut chunk = tracker.track_chunk(
|
||||
endpoint(1, 12000),
|
||||
ContentId::from_bytes([2; 32]),
|
||||
&path("file.bin"),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
chunk.record_bytes(25);
|
||||
|
||||
assert_eq!(tracker.raw_downloaded_bytes(), 25);
|
||||
@@ -319,17 +350,22 @@ mod tests {
|
||||
#[test]
|
||||
fn tracker_reports_unique_active_peer_count() {
|
||||
let tracker = DownloadProgressTracker::new(100);
|
||||
let first_peer = loopback_addr(12000);
|
||||
let second_peer = loopback_addr(12001);
|
||||
let first_peer = endpoint(1, 12000);
|
||||
let moved_first_peer = endpoint(1, 12002);
|
||||
let second_peer = endpoint(2, 12001);
|
||||
let content_id = ContentId::from_bytes([3; 32]);
|
||||
let file = path("file.bin");
|
||||
let other = path("other.bin");
|
||||
let attempt = DownloadAttemptKey::next("game".to_owned());
|
||||
|
||||
{
|
||||
let _first_chunk = tracker.track_chunk(first_peer, "game/file.bin", 0, 50);
|
||||
let _second_chunk = tracker.track_chunk(first_peer, "game/file.bin", 50, 50);
|
||||
let _third_chunk = tracker.track_chunk(second_peer, "game/other.bin", 0, 10);
|
||||
let _first_chunk = tracker.track_chunk(first_peer, content_id, &file, 0, 50);
|
||||
let _second_chunk = tracker.track_chunk(moved_first_peer, content_id, &file, 50, 50);
|
||||
let _third_chunk = tracker.track_chunk(second_peer, content_id, &other, 0, 10);
|
||||
|
||||
assert_eq!(tracker.snapshot("game", 0).active_peer_count, 2);
|
||||
assert_eq!(tracker.snapshot(&attempt, 0).active_peer_count, 2);
|
||||
}
|
||||
|
||||
assert_eq!(tracker.snapshot("game", 0).active_peer_count, 0);
|
||||
assert_eq!(tracker.snapshot(&attempt, 0).active_peer_count, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +1,75 @@
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
net::SocketAddr,
|
||||
collections::{HashMap, HashSet, VecDeque},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use futures::stream::FuturesUnordered;
|
||||
use lanspread_db::content_manifest::ContentId;
|
||||
use lanspread_proto::PeerEndpoint;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::{
|
||||
DownloadTransferError,
|
||||
DownloadTransferErrorKind,
|
||||
confined_fs::ConfinedGameRoot,
|
||||
planning::{ChunkDownloadResult, DownloadChunk, PeerDownloadPlan, resolve_file_peers},
|
||||
planning::{ChunkDownloadResult, DownloadChunk, PeerDownloadPlan, reconcile_chunk_results},
|
||||
progress::DownloadProgressTracker,
|
||||
task_drain::collect_or_drain_on_cancel,
|
||||
transport::download_from_peer,
|
||||
transport::{PeerDownloadRequest, download_from_peer},
|
||||
version_ini::VersionIniBuffer,
|
||||
};
|
||||
use crate::config::MAX_RETRY_COUNT;
|
||||
use crate::{
|
||||
DownloadVerificationActivity,
|
||||
content_quarantine::ContentQuarantine,
|
||||
peer_db::PeerId,
|
||||
quic_runtime::QuicConnector,
|
||||
transfer_status::DownloadAttemptReporter,
|
||||
};
|
||||
|
||||
/// Selects a peer for retrying a failed chunk.
|
||||
fn select_retry_peer(peers: &[SocketAddr], last_peer: Option<SocketAddr>) -> Option<SocketAddr> {
|
||||
if peers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if peers.len() > 1
|
||||
&& let Some(last) = last_peer
|
||||
&& let Some(pos) = peers.iter().position(|addr| *addr == last)
|
||||
{
|
||||
let next_index = (pos + 1) % peers.len();
|
||||
return Some(peers[next_index]);
|
||||
}
|
||||
|
||||
peers.first().copied()
|
||||
/// One failed chunk plus the exact authenticated sources already attempted.
|
||||
///
|
||||
/// Transport addresses deliberately do not participate in retry identity. A
|
||||
/// peer that rotates its address is still one attempted source for this chunk.
|
||||
#[derive(Debug)]
|
||||
pub(super) struct RetryChunk {
|
||||
chunk: DownloadChunk,
|
||||
attempted_peer_ids: HashSet<PeerId>,
|
||||
last_source: PeerEndpoint,
|
||||
last_error: DownloadTransferError,
|
||||
}
|
||||
|
||||
/// Returns a fallback peer address for error reporting.
|
||||
fn fallback_peer_addr(peers: &[SocketAddr], last_peer: Option<SocketAddr>) -> SocketAddr {
|
||||
last_peer
|
||||
.or_else(|| peers.first().copied())
|
||||
.unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 0)))
|
||||
impl RetryChunk {
|
||||
pub(super) fn after_failure(
|
||||
chunk: DownloadChunk,
|
||||
source: PeerEndpoint,
|
||||
error: DownloadTransferError,
|
||||
) -> Self {
|
||||
Self {
|
||||
chunk,
|
||||
attempted_peer_ids: HashSet::from([source.peer_id]),
|
||||
last_source: source,
|
||||
last_error: error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct RetryContext<'a> {
|
||||
pub(super) sources: &'a [PeerEndpoint],
|
||||
pub(super) content_id: ContentId,
|
||||
pub(super) quarantine: &'a ContentQuarantine,
|
||||
pub(super) game_root: &'a ConfinedGameRoot,
|
||||
pub(super) game_id: &'a str,
|
||||
pub(super) cancel_token: &'a CancellationToken,
|
||||
pub(super) quic: &'a QuicConnector,
|
||||
pub(super) version_buffer: Arc<VersionIniBuffer>,
|
||||
pub(super) progress_tracker: Arc<DownloadProgressTracker>,
|
||||
pub(super) attempt: DownloadAttemptReporter,
|
||||
}
|
||||
|
||||
struct RetryAttempt {
|
||||
source: PeerEndpoint,
|
||||
chunks: Vec<RetryChunk>,
|
||||
result: Result<Vec<ChunkDownloadResult>, DownloadTransferError>,
|
||||
}
|
||||
|
||||
fn ensure_not_cancelled(cancel_token: &CancellationToken, game_id: &str) -> eyre::Result<()> {
|
||||
@@ -48,96 +79,92 @@ fn ensure_not_cancelled(cancel_token: &CancellationToken, game_id: &str) -> eyre
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct RetryAttempt {
|
||||
peer_addr: SocketAddr,
|
||||
chunks: Vec<DownloadChunk>,
|
||||
result: eyre::Result<Vec<ChunkDownloadResult>>,
|
||||
}
|
||||
|
||||
pub(super) struct RetryContext<'a> {
|
||||
pub(super) peers: &'a [SocketAddr],
|
||||
pub(super) game_root: &'a ConfinedGameRoot,
|
||||
pub(super) game_id: &'a str,
|
||||
pub(super) file_peer_map: &'a HashMap<String, Vec<SocketAddr>>,
|
||||
pub(super) cancel_token: &'a CancellationToken,
|
||||
pub(super) version_buffer: Option<Arc<VersionIniBuffer>>,
|
||||
pub(super) progress_tracker: Arc<DownloadProgressTracker>,
|
||||
fn select_retry_source<'a>(
|
||||
sources: &'a [PeerEndpoint],
|
||||
attempted_peer_ids: &HashSet<PeerId>,
|
||||
content_id: ContentId,
|
||||
quarantine: &ContentQuarantine,
|
||||
) -> Option<&'a PeerEndpoint> {
|
||||
let mut seen_peer_ids = HashSet::new();
|
||||
sources.iter().find(|source| {
|
||||
seen_peer_ids.insert(source.peer_id)
|
||||
&& !attempted_peer_ids.contains(&source.peer_id)
|
||||
&& !quarantine.is_quarantined(source, content_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn plan_retry_batch(
|
||||
queue: &mut VecDeque<DownloadChunk>,
|
||||
peers: &[SocketAddr],
|
||||
file_peer_map: &HashMap<String, Vec<SocketAddr>>,
|
||||
queue: &mut VecDeque<RetryChunk>,
|
||||
ctx: &RetryContext<'_>,
|
||||
final_results: &mut Vec<ChunkDownloadResult>,
|
||||
) -> HashMap<SocketAddr, PeerDownloadPlan> {
|
||||
let mut retry_plans: HashMap<SocketAddr, PeerDownloadPlan> = HashMap::new();
|
||||
) -> HashMap<PeerEndpoint, Vec<RetryChunk>> {
|
||||
let mut retry_plans: HashMap<PeerEndpoint, Vec<RetryChunk>> = HashMap::new();
|
||||
|
||||
while let Some(mut chunk) = queue.pop_front() {
|
||||
let eligible_peers = resolve_file_peers(&chunk.request_path, file_peer_map, peers);
|
||||
|
||||
if chunk.retry_count >= MAX_RETRY_COUNT {
|
||||
while let Some(mut retry) = queue.pop_front() {
|
||||
let Some(source) = select_retry_source(
|
||||
ctx.sources,
|
||||
&retry.attempted_peer_ids,
|
||||
ctx.content_id,
|
||||
ctx.quarantine,
|
||||
) else {
|
||||
final_results.push(ChunkDownloadResult {
|
||||
chunk: chunk.clone(),
|
||||
result: Err(eyre::eyre!(
|
||||
"Retry budget exhausted for chunk: {}",
|
||||
chunk.request_path
|
||||
)),
|
||||
peer_addr: fallback_peer_addr(eligible_peers, chunk.last_peer),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(peer_addr) = select_retry_peer(eligible_peers, chunk.last_peer) else {
|
||||
final_results.push(ChunkDownloadResult {
|
||||
chunk: chunk.clone(),
|
||||
result: Err(eyre::eyre!(
|
||||
"No peers available to retry chunk: {}",
|
||||
chunk.request_path
|
||||
)),
|
||||
peer_addr: fallback_peer_addr(eligible_peers, chunk.last_peer),
|
||||
chunk: retry.chunk,
|
||||
result: Err(retry.last_error),
|
||||
peer_endpoint: retry.last_source,
|
||||
});
|
||||
continue;
|
||||
};
|
||||
|
||||
chunk.last_peer = Some(peer_addr);
|
||||
retry_plans.entry(peer_addr).or_default().chunks.push(chunk);
|
||||
if retry.last_error.kind() == DownloadTransferErrorKind::Integrity {
|
||||
ctx.attempt
|
||||
.set_activity(DownloadVerificationActivity::RetryingInvalidSource);
|
||||
}
|
||||
|
||||
retry.attempted_peer_ids.insert(source.peer_id);
|
||||
retry.last_source = *source;
|
||||
retry_plans.entry(*source).or_default().push(retry);
|
||||
}
|
||||
|
||||
retry_plans
|
||||
}
|
||||
|
||||
async fn run_retry_batch(
|
||||
retry_plans: HashMap<SocketAddr, PeerDownloadPlan>,
|
||||
retry_plans: HashMap<PeerEndpoint, Vec<RetryChunk>>,
|
||||
ctx: &RetryContext<'_>,
|
||||
) -> eyre::Result<Vec<RetryAttempt>> {
|
||||
let attempts = FuturesUnordered::new();
|
||||
|
||||
for (peer_addr, plan) in retry_plans {
|
||||
for (source, chunks) in retry_plans {
|
||||
if ctx.cancel_token.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
|
||||
let retry_chunks = plan.chunks.clone();
|
||||
let plan = PeerDownloadPlan {
|
||||
chunks: chunks.iter().map(|retry| retry.chunk.clone()).collect(),
|
||||
};
|
||||
let game_root = ctx.game_root.clone();
|
||||
let game_id = ctx.game_id.to_string();
|
||||
let cancel_token = ctx.cancel_token.clone();
|
||||
let version_buffer = ctx.version_buffer.clone();
|
||||
let progress_tracker = ctx.progress_tracker.clone();
|
||||
let quic = ctx.quic.clone();
|
||||
let endpoint = source;
|
||||
|
||||
attempts.push(async move {
|
||||
let result = download_from_peer(
|
||||
peer_addr,
|
||||
&game_id,
|
||||
let result = download_from_peer(PeerDownloadRequest {
|
||||
quic,
|
||||
endpoint,
|
||||
game_id,
|
||||
plan,
|
||||
game_root,
|
||||
&cancel_token,
|
||||
cancel_token,
|
||||
version_buffer,
|
||||
progress_tracker,
|
||||
)
|
||||
})
|
||||
.await;
|
||||
RetryAttempt {
|
||||
peer_addr,
|
||||
chunks: retry_chunks,
|
||||
source,
|
||||
chunks,
|
||||
result,
|
||||
}
|
||||
});
|
||||
@@ -146,111 +173,154 @@ async fn run_retry_batch(
|
||||
collect_or_drain_on_cancel(attempts, ctx.cancel_token, ctx.game_id).await
|
||||
}
|
||||
|
||||
fn handle_retry_chunk_result(
|
||||
result: ChunkDownloadResult,
|
||||
queue: &mut VecDeque<DownloadChunk>,
|
||||
pub(super) fn quarantine_if_integrity_failure(
|
||||
quarantine: &ContentQuarantine,
|
||||
source: &PeerEndpoint,
|
||||
content_id: ContentId,
|
||||
error: &DownloadTransferError,
|
||||
) {
|
||||
if error.kind() == DownloadTransferErrorKind::Integrity {
|
||||
quarantine.record_integrity_failure(source, content_id);
|
||||
}
|
||||
}
|
||||
|
||||
struct RetryFailurePolicy<'a> {
|
||||
content_id: ContentId,
|
||||
quarantine: &'a ContentQuarantine,
|
||||
}
|
||||
|
||||
fn handle_retry_attempt_error(
|
||||
source: &PeerEndpoint,
|
||||
chunks: Vec<RetryChunk>,
|
||||
error: &DownloadTransferError,
|
||||
policy: &RetryFailurePolicy<'_>,
|
||||
queue: &mut VecDeque<RetryChunk>,
|
||||
final_results: &mut Vec<ChunkDownloadResult>,
|
||||
) {
|
||||
let ChunkDownloadResult {
|
||||
mut chunk,
|
||||
result,
|
||||
peer_addr,
|
||||
} = result;
|
||||
let kind = error.kind();
|
||||
quarantine_if_integrity_failure(policy.quarantine, source, policy.content_id, error);
|
||||
|
||||
match result {
|
||||
Ok(()) => final_results.push(ChunkDownloadResult {
|
||||
chunk,
|
||||
result: Ok(()),
|
||||
peer_addr,
|
||||
}),
|
||||
Err(err) => {
|
||||
chunk.retry_count += 1;
|
||||
chunk.last_peer = Some(peer_addr);
|
||||
|
||||
if chunk.retry_count >= MAX_RETRY_COUNT {
|
||||
let context = format!("Retry budget exhausted for chunk: {}", chunk.request_path);
|
||||
for mut retry in chunks {
|
||||
let error = error.clone();
|
||||
retry.last_source = *source;
|
||||
match kind {
|
||||
DownloadTransferErrorKind::Integrity | DownloadTransferErrorKind::Transport => {
|
||||
retry.last_error = error;
|
||||
queue.push_back(retry);
|
||||
}
|
||||
DownloadTransferErrorKind::LocalIo | DownloadTransferErrorKind::Cancelled => {
|
||||
final_results.push(ChunkDownloadResult {
|
||||
chunk,
|
||||
result: Err(err.wrap_err(context)),
|
||||
peer_addr,
|
||||
chunk: retry.chunk,
|
||||
result: Err(error),
|
||||
peer_endpoint: *source,
|
||||
});
|
||||
} else {
|
||||
queue.push_back(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_retry_attempt_error(
|
||||
peer_addr: SocketAddr,
|
||||
chunks: Vec<DownloadChunk>,
|
||||
err: &eyre::Report,
|
||||
queue: &mut VecDeque<DownloadChunk>,
|
||||
fn handle_retry_chunk_result(
|
||||
mut retry: RetryChunk,
|
||||
result: ChunkDownloadResult,
|
||||
source: &PeerEndpoint,
|
||||
ctx: &RetryContext<'_>,
|
||||
queue: &mut VecDeque<RetryChunk>,
|
||||
final_results: &mut Vec<ChunkDownloadResult>,
|
||||
) {
|
||||
let error = err.to_string();
|
||||
let peer_endpoint = result.peer_endpoint;
|
||||
match result.result {
|
||||
Ok(()) => final_results.push(ChunkDownloadResult {
|
||||
chunk: retry.chunk,
|
||||
result: Ok(()),
|
||||
peer_endpoint,
|
||||
}),
|
||||
Err(error) => {
|
||||
quarantine_if_integrity_failure(ctx.quarantine, source, ctx.content_id, &error);
|
||||
retry.last_source = *source;
|
||||
|
||||
for mut chunk in chunks {
|
||||
chunk.retry_count += 1;
|
||||
chunk.last_peer = Some(peer_addr);
|
||||
|
||||
if chunk.retry_count >= MAX_RETRY_COUNT {
|
||||
final_results.push(ChunkDownloadResult {
|
||||
chunk: chunk.clone(),
|
||||
result: Err(eyre::eyre!(
|
||||
"Retry budget exhausted for chunk after connection failure: {}: {error}",
|
||||
chunk.request_path
|
||||
)),
|
||||
peer_addr,
|
||||
});
|
||||
} else {
|
||||
queue.push_back(chunk);
|
||||
match error.kind() {
|
||||
DownloadTransferErrorKind::Integrity | DownloadTransferErrorKind::Transport => {
|
||||
retry.last_error = error;
|
||||
queue.push_back(retry);
|
||||
}
|
||||
DownloadTransferErrorKind::LocalIo | DownloadTransferErrorKind::Cancelled => {
|
||||
final_results.push(ChunkDownloadResult {
|
||||
chunk: retry.chunk,
|
||||
result: Err(error),
|
||||
peer_endpoint,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retries downloading failed chunks.
|
||||
fn handle_retry_attempt(
|
||||
attempt: RetryAttempt,
|
||||
ctx: &RetryContext<'_>,
|
||||
queue: &mut VecDeque<RetryChunk>,
|
||||
final_results: &mut Vec<ChunkDownloadResult>,
|
||||
) -> eyre::Result<()> {
|
||||
let RetryAttempt {
|
||||
source,
|
||||
chunks,
|
||||
result,
|
||||
} = attempt;
|
||||
let results = match result {
|
||||
Ok(results) => results,
|
||||
Err(error) => {
|
||||
let policy = RetryFailurePolicy {
|
||||
content_id: ctx.content_id,
|
||||
quarantine: ctx.quarantine,
|
||||
};
|
||||
handle_retry_attempt_error(&source, chunks, &error, &policy, queue, final_results);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let expected_endpoint = source;
|
||||
for (retry, result) in reconcile_chunk_results(
|
||||
chunks,
|
||||
results,
|
||||
expected_endpoint,
|
||||
|retry| &retry.chunk,
|
||||
"retry",
|
||||
)? {
|
||||
handle_retry_chunk_result(retry, result, &source, ctx, queue, final_results);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retries failed chunks against every eligible, nonquarantined peer identity.
|
||||
///
|
||||
/// Each source is attempted at most once per chunk. There is no numeric retry
|
||||
/// cap: terminal failure means the complete eligible source set was exhausted.
|
||||
pub(super) async fn retry_failed_chunks(
|
||||
failed_chunks: Vec<DownloadChunk>,
|
||||
failed_chunks: Vec<RetryChunk>,
|
||||
ctx: &RetryContext<'_>,
|
||||
) -> eyre::Result<Vec<ChunkDownloadResult>> {
|
||||
if failed_chunks
|
||||
.iter()
|
||||
.any(|retry| retry.chunk.content_id != ctx.content_id)
|
||||
{
|
||||
eyre::bail!(
|
||||
"retry plan content ID does not match requested catalog authority for game {}",
|
||||
ctx.game_id
|
||||
);
|
||||
}
|
||||
let mut final_results = Vec::new();
|
||||
let mut queue: VecDeque<DownloadChunk> = failed_chunks.into_iter().collect();
|
||||
let mut queue: VecDeque<RetryChunk> = failed_chunks.into_iter().collect();
|
||||
|
||||
while !queue.is_empty() {
|
||||
ensure_not_cancelled(ctx.cancel_token, ctx.game_id)?;
|
||||
|
||||
let retry_plans =
|
||||
plan_retry_batch(&mut queue, ctx.peers, ctx.file_peer_map, &mut final_results);
|
||||
let retry_plans = plan_retry_batch(&mut queue, ctx, &mut final_results);
|
||||
if retry_plans.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let attempts = run_retry_batch(retry_plans, ctx).await?;
|
||||
|
||||
for attempt in attempts {
|
||||
let RetryAttempt {
|
||||
peer_addr,
|
||||
chunks,
|
||||
result,
|
||||
} = attempt;
|
||||
|
||||
match result {
|
||||
Ok(results) => {
|
||||
for result in results {
|
||||
handle_retry_chunk_result(result, &mut queue, &mut final_results);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
handle_retry_attempt_error(
|
||||
peer_addr,
|
||||
chunks,
|
||||
&err,
|
||||
&mut queue,
|
||||
&mut final_results,
|
||||
);
|
||||
}
|
||||
}
|
||||
for attempt in run_retry_batch(retry_plans, ctx).await? {
|
||||
handle_retry_attempt(attempt, ctx, &mut queue, &mut final_results)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,37 +329,206 @@ pub(super) async fn retry_failed_chunks(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use lanspread_db::content_manifest::{
|
||||
Blake3Digest,
|
||||
CatalogContentManifest,
|
||||
CatalogContentManifestBody,
|
||||
CatalogFileEntry,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::TempDir;
|
||||
|
||||
fn loopback_addr(port: u16) -> SocketAddr {
|
||||
SocketAddr::from(([127, 0, 0, 1], port))
|
||||
fn source(peer_id: &str, port: u16) -> PeerEndpoint {
|
||||
PeerEndpoint::new(
|
||||
PeerId::from_bytes(*blake3::hash(peer_id.as_bytes()).as_bytes()),
|
||||
SocketAddr::from(([127, 0, 0, 1], port)),
|
||||
)
|
||||
}
|
||||
|
||||
fn content(seed: u8) -> ContentId {
|
||||
ContentId::from_bytes([seed; 32])
|
||||
}
|
||||
|
||||
fn chunk() -> DownloadChunk {
|
||||
let version_digest = Blake3Digest::hash(b"1");
|
||||
let catalog = Arc::new(
|
||||
CatalogContentManifest::seal(
|
||||
CatalogContentManifestBody::new(
|
||||
"game",
|
||||
"1",
|
||||
vec![
|
||||
CatalogFileEntry::file(
|
||||
"version.ini",
|
||||
1,
|
||||
version_digest,
|
||||
vec![version_digest],
|
||||
)
|
||||
.expect("test catalog entry should validate"),
|
||||
],
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("test catalog body should validate"),
|
||||
)
|
||||
.expect("test catalog should seal"),
|
||||
);
|
||||
let temp = TempDir::new("lanspread-retry-chunk");
|
||||
let manifest =
|
||||
super::super::manifest::ValidatedDownloadManifest::from_catalog(temp.path(), catalog)
|
||||
.expect("test download manifest should validate");
|
||||
let entry = manifest.version_entry();
|
||||
DownloadChunk {
|
||||
content_id: manifest.content_id(),
|
||||
destination: entry.destination().clone(),
|
||||
offset: 0,
|
||||
length: entry.size(),
|
||||
expected_blake3: manifest
|
||||
.expected_blake3(entry, Some(0))
|
||||
.expect("test chunk digest should exist"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_peer_selection_cycles_after_last_failed_peer() {
|
||||
let peers = vec![
|
||||
loopback_addr(12000),
|
||||
loopback_addr(12001),
|
||||
loopback_addr(12002),
|
||||
];
|
||||
fn source_selection_exhausts_more_than_three_unique_peer_ids() {
|
||||
let sources = (0_u16..5)
|
||||
.map(|index| source(&format!("peer-{index}"), 12000 + index))
|
||||
.collect::<Vec<_>>();
|
||||
let quarantine = ContentQuarantine::default();
|
||||
let content_id = content(1);
|
||||
let mut attempted = HashSet::new();
|
||||
let mut selected = Vec::new();
|
||||
|
||||
assert_eq!(select_retry_peer(&peers, Some(peers[0])), Some(peers[1]));
|
||||
assert_eq!(select_retry_peer(&peers, Some(peers[1])), Some(peers[2]));
|
||||
assert_eq!(select_retry_peer(&peers, Some(peers[2])), Some(peers[0]));
|
||||
while let Some(source) = select_retry_source(&sources, &attempted, content_id, &quarantine)
|
||||
{
|
||||
attempted.insert(source.peer_id);
|
||||
selected.push(source.peer_id);
|
||||
}
|
||||
|
||||
assert_eq!(selected.len(), 5);
|
||||
assert_eq!(selected.first().copied(), Some(source("peer-0", 0).peer_id));
|
||||
assert_eq!(selected.last().copied(), Some(source("peer-4", 0).peer_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_peer_selection_uses_first_peer_without_prior_failure() {
|
||||
let peers = vec![loopback_addr(12000), loopback_addr(12001)];
|
||||
fn newly_quarantined_source_is_skipped_before_the_next_selection() {
|
||||
let bad = source("bad", 12000);
|
||||
let good = source("good", 12001);
|
||||
let sources = vec![bad, good];
|
||||
let quarantine = ContentQuarantine::default();
|
||||
let content_id = content(2);
|
||||
|
||||
assert_eq!(select_retry_peer(&peers, None), Some(peers[0]));
|
||||
assert_eq!(
|
||||
select_retry_source(&sources, &HashSet::new(), content_id, &quarantine),
|
||||
Some(&bad)
|
||||
);
|
||||
quarantine.record_integrity_failure(&bad, content_id);
|
||||
|
||||
assert_eq!(
|
||||
select_retry_source(&sources, &HashSet::new(), content_id, &quarantine),
|
||||
Some(&good)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_peer_selection_wraps_between_two_peers() {
|
||||
let peers = vec![loopback_addr(12000), loopback_addr(12001)];
|
||||
fn transport_and_local_errors_never_quarantine_content() {
|
||||
let source = source("peer", 12000);
|
||||
let content_id = content(3);
|
||||
|
||||
assert_eq!(select_retry_peer(&peers, Some(peers[0])), Some(peers[1]));
|
||||
assert_eq!(select_retry_peer(&peers, Some(peers[1])), Some(peers[0]));
|
||||
for error in [
|
||||
DownloadTransferError::transport("offline"),
|
||||
DownloadTransferError::local_io("disk full"),
|
||||
DownloadTransferError::cancelled("game"),
|
||||
] {
|
||||
let quarantine = ContentQuarantine::default();
|
||||
quarantine_if_integrity_failure(&quarantine, &source, content_id, &error);
|
||||
assert!(!quarantine.is_quarantined(&source, content_id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_typed_integrity_error_quarantines_exact_peer_content_pair() {
|
||||
let source = source("peer", 12000);
|
||||
let content_id = content(4);
|
||||
let quarantine = ContentQuarantine::default();
|
||||
let error = DownloadTransferError::integrity("bad hash");
|
||||
|
||||
quarantine_if_integrity_failure(&quarantine, &source, content_id, &error);
|
||||
|
||||
assert!(quarantine.is_quarantined(&source, content_id));
|
||||
assert!(!quarantine.is_quarantined(&source, content(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_attempt_transport_failure_requeues_every_planned_chunk() {
|
||||
let initial = source("initial", 12000);
|
||||
let retry_source = source("retry", 12001);
|
||||
let content_id = content(6);
|
||||
let quarantine = ContentQuarantine::default();
|
||||
let policy = RetryFailurePolicy {
|
||||
content_id,
|
||||
quarantine: &quarantine,
|
||||
};
|
||||
let mut first = RetryChunk::after_failure(
|
||||
chunk(),
|
||||
initial,
|
||||
DownloadTransferError::transport("initial failed"),
|
||||
);
|
||||
first.attempted_peer_ids.insert(retry_source.peer_id);
|
||||
let mut second = RetryChunk::after_failure(
|
||||
chunk(),
|
||||
initial,
|
||||
DownloadTransferError::transport("initial failed"),
|
||||
);
|
||||
second.attempted_peer_ids.insert(retry_source.peer_id);
|
||||
let mut queue = VecDeque::new();
|
||||
let mut final_results = Vec::new();
|
||||
|
||||
handle_retry_attempt_error(
|
||||
&retry_source,
|
||||
vec![first, second],
|
||||
&DownloadTransferError::transport("connection failed"),
|
||||
&policy,
|
||||
&mut queue,
|
||||
&mut final_results,
|
||||
);
|
||||
|
||||
assert_eq!(queue.len(), 2);
|
||||
assert!(final_results.is_empty());
|
||||
assert!(queue.iter().all(|retry| retry.last_error.kind()
|
||||
== DownloadTransferErrorKind::Transport
|
||||
&& retry.last_source == retry_source
|
||||
&& retry.attempted_peer_ids.contains(&retry_source.peer_id)));
|
||||
assert!(!quarantine.is_quarantined(&retry_source, content_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_reconciliation_rejects_endpoint_misattribution() {
|
||||
let initial = source("initial", 12010);
|
||||
let expected = source("expected", 12011);
|
||||
let unexpected = source("unexpected", 12012);
|
||||
let planned_chunk = chunk();
|
||||
let retry = RetryChunk::after_failure(
|
||||
planned_chunk.clone(),
|
||||
initial,
|
||||
DownloadTransferError::transport("initial failed"),
|
||||
);
|
||||
let expected_endpoint = PeerEndpoint::new(expected.peer_id, expected.addr);
|
||||
|
||||
let error = reconcile_chunk_results(
|
||||
vec![retry],
|
||||
vec![ChunkDownloadResult {
|
||||
chunk: planned_chunk,
|
||||
result: Ok(()),
|
||||
peer_endpoint: PeerEndpoint::new(unexpected.peer_id, unexpected.addr),
|
||||
}],
|
||||
expected_endpoint,
|
||||
|retry| &retry.chunk,
|
||||
"retry",
|
||||
)
|
||||
.expect_err("retry endpoint mismatch must fail closed");
|
||||
|
||||
assert!(error.to_string().contains("unexpected endpoint"));
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,59 @@
|
||||
use super::{confined_fs::ConfinedGameRoot, manifest::ValidatedDownloadManifest};
|
||||
|
||||
/// Prepares storage for game files by creating directories and pre-allocating files.
|
||||
pub(super) async fn prepare_game_storage(
|
||||
pub(super) fn prepare_game_storage(
|
||||
manifest: &ValidatedDownloadManifest,
|
||||
game_root: &ConfinedGameRoot,
|
||||
) -> eyre::Result<()> {
|
||||
game_root
|
||||
.prepare_entries(manifest.transfer_entries().cloned().collect())
|
||||
.await
|
||||
game_root.prepare_entries(manifest.transfer_entries().cloned().collect())
|
||||
}
|
||||
|
||||
/// Makes payload bytes and directory entries durable before the sentinel commit.
|
||||
pub(super) async fn sync_game_storage(
|
||||
pub(super) fn sync_game_storage(
|
||||
manifest: &ValidatedDownloadManifest,
|
||||
game_root: &ConfinedGameRoot,
|
||||
) -> eyre::Result<()> {
|
||||
game_root
|
||||
.sync_entries(manifest.transfer_entries().cloned().collect())
|
||||
.await
|
||||
game_root.sync_entries(manifest.transfer_entries().cloned().collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use lanspread_db::db::{GameCatalog, GameFileDescription};
|
||||
use std::sync::Arc;
|
||||
|
||||
use lanspread_db::content_manifest::{
|
||||
Blake3Digest,
|
||||
CatalogContentManifest,
|
||||
CatalogContentManifestBody,
|
||||
CatalogFileEntry,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_game_storage_skips_version_ini_sentinel() {
|
||||
#[test]
|
||||
fn prepare_game_storage_skips_version_ini_sentinel() {
|
||||
let temp = TempDir::new("lanspread-download");
|
||||
let descs = vec![GameFileDescription {
|
||||
game_id: "game".to_string(),
|
||||
relative_path: "game/version.ini".to_string(),
|
||||
is_dir: false,
|
||||
size: 8,
|
||||
}];
|
||||
let manifest = ValidatedDownloadManifest::from_protocol_v7(
|
||||
temp.path(),
|
||||
"game",
|
||||
descs,
|
||||
&GameCatalog::from_ids(["game".to_owned()]),
|
||||
let version = b"20250101";
|
||||
let digest = Blake3Digest::hash(version);
|
||||
let catalog = CatalogContentManifest::seal(
|
||||
CatalogContentManifestBody::new(
|
||||
"game",
|
||||
"20250101",
|
||||
vec![
|
||||
CatalogFileEntry::file("version.ini", 8, digest, vec![digest])
|
||||
.expect("version.ini entry should validate"),
|
||||
],
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("catalog body should validate"),
|
||||
)
|
||||
.expect("manifest should validate");
|
||||
.expect("catalog manifest should seal");
|
||||
let manifest = ValidatedDownloadManifest::from_catalog(temp.path(), Arc::new(catalog))
|
||||
.expect("manifest should validate");
|
||||
|
||||
let game_root = ConfinedGameRoot::open_or_create(temp.path(), "game")
|
||||
.await
|
||||
.expect("confined game root should open");
|
||||
prepare_game_storage(&manifest, &game_root)
|
||||
.await
|
||||
.expect("storage preparation should succeed");
|
||||
prepare_game_storage(&manifest, &game_root).expect("storage preparation should succeed");
|
||||
|
||||
assert!(!temp.path().join("game").join("version.ini").exists());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Typed failure boundary for ordinary catalog-content transfers.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Stable classification used by retry and source-quarantine policy.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum DownloadTransferErrorKind {
|
||||
/// The source supplied bytes or a byte count that disagrees with the local
|
||||
/// catalog authority.
|
||||
Integrity,
|
||||
/// QUIC connection, request, or receive failure.
|
||||
Transport,
|
||||
/// Local filesystem or destination-buffer failure.
|
||||
LocalIo,
|
||||
/// The owning download operation was cancelled.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// One ordinary-transfer failure with a policy-safe classification.
|
||||
///
|
||||
/// Callers must branch on [`Self::kind`], never parse the diagnostic text.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct DownloadTransferError {
|
||||
kind: DownloadTransferErrorKind,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl DownloadTransferError {
|
||||
#[must_use]
|
||||
pub(crate) fn integrity(message: impl Into<String>) -> Self {
|
||||
Self::new(DownloadTransferErrorKind::Integrity, message)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn transport(message: impl Into<String>) -> Self {
|
||||
Self::new(DownloadTransferErrorKind::Transport, message)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn local_io(message: impl Into<String>) -> Self {
|
||||
Self::new(DownloadTransferErrorKind::LocalIo, message)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn cancelled(game_id: &str) -> Self {
|
||||
Self::new(
|
||||
DownloadTransferErrorKind::Cancelled,
|
||||
format!("download cancelled for game {game_id}"),
|
||||
)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) const fn kind(&self) -> DownloadTransferErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
fn new(kind: DownloadTransferErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DownloadTransferError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DownloadTransferError {}
|
||||
|
||||
pub(crate) type DownloadTransferResult<T> = Result<T, DownloadTransferError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifications_are_explicit_and_text_independent() {
|
||||
let cases = [
|
||||
(
|
||||
DownloadTransferError::integrity("same diagnostic"),
|
||||
DownloadTransferErrorKind::Integrity,
|
||||
),
|
||||
(
|
||||
DownloadTransferError::transport("same diagnostic"),
|
||||
DownloadTransferErrorKind::Transport,
|
||||
),
|
||||
(
|
||||
DownloadTransferError::local_io("same diagnostic"),
|
||||
DownloadTransferErrorKind::LocalIo,
|
||||
),
|
||||
(
|
||||
DownloadTransferError::cancelled("game"),
|
||||
DownloadTransferErrorKind::Cancelled,
|
||||
),
|
||||
];
|
||||
|
||||
for (error, expected) in cases {
|
||||
assert_eq!(error.kind(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
use std::{collections::VecDeque, net::SocketAddr, sync::Arc};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
fs::File,
|
||||
future::Future,
|
||||
io::{Seek as _, SeekFrom},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use futures::{SinkExt, StreamExt, stream::FuturesUnordered};
|
||||
use lanspread_db::content_manifest::Blake3Digest;
|
||||
use lanspread_proto::{ControlMessage, PeerEndpoint, Request};
|
||||
use s2n_quic::{Connection, stream::ReceiveStream};
|
||||
use tokio::io::{AsyncSeekExt, AsyncWrite, AsyncWriteExt};
|
||||
use tokio::time::{self, Instant};
|
||||
use tokio_util::{
|
||||
codec::{FramedWrite, LengthDelimitedCodec},
|
||||
sync::CancellationToken,
|
||||
@@ -12,20 +21,96 @@ use super::{
|
||||
confined_fs::ConfinedGameRoot,
|
||||
planning::{ChunkDownloadResult, DownloadChunk, PeerDownloadPlan},
|
||||
progress::DownloadProgressTracker,
|
||||
transfer_error::{DownloadTransferError, DownloadTransferErrorKind, DownloadTransferResult},
|
||||
version_ini::VersionIniBuffer,
|
||||
};
|
||||
use crate::{config::PEER_DOWNLOAD_STREAM_WINDOW, network::connect_to_peer};
|
||||
use crate::{
|
||||
config::PEER_DOWNLOAD_STREAM_WINDOW,
|
||||
network::connect_to_peer,
|
||||
quic_runtime::QuicConnector,
|
||||
scoped_blocking::scoped_blocking,
|
||||
};
|
||||
|
||||
const ORDINARY_CHUNK_TRANSFER_TIMEOUT: Duration = Duration::from_mins(10);
|
||||
|
||||
fn ensure_download_not_cancelled(
|
||||
cancel_token: &CancellationToken,
|
||||
game_id: &str,
|
||||
) -> eyre::Result<()> {
|
||||
) -> DownloadTransferResult<()> {
|
||||
if cancel_token.is_cancelled() {
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
return Err(DownloadTransferError::cancelled(game_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An application-owned absolute deadline for one catalog chunk.
|
||||
///
|
||||
/// QUIC keep-alive traffic is intentionally irrelevant here: only completing
|
||||
/// this chunk before the deadline succeeds. `run` checks again after polling
|
||||
/// the operation so finite synchronous work cannot outlive a reported timeout.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ChunkDeadline {
|
||||
expires_at: Instant,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl ChunkDeadline {
|
||||
fn ordinary() -> Self {
|
||||
Self::after(ORDINARY_CHUNK_TRANSFER_TIMEOUT)
|
||||
}
|
||||
|
||||
fn after(timeout: Duration) -> Self {
|
||||
Self {
|
||||
expires_at: Instant::now() + timeout,
|
||||
timeout,
|
||||
}
|
||||
}
|
||||
|
||||
fn timeout_error(self, canonical_path: &str, offset: u64) -> DownloadTransferError {
|
||||
let timeout = self.timeout;
|
||||
DownloadTransferError::transport(format!(
|
||||
"catalog chunk transfer timed out after {timeout:?} for {canonical_path} at offset {offset}"
|
||||
))
|
||||
}
|
||||
|
||||
fn ensure_active(
|
||||
self,
|
||||
cancel_token: &CancellationToken,
|
||||
game_id: &str,
|
||||
canonical_path: &str,
|
||||
offset: u64,
|
||||
) -> DownloadTransferResult<()> {
|
||||
ensure_download_not_cancelled(cancel_token, game_id)?;
|
||||
if Instant::now() >= self.expires_at {
|
||||
return Err(self.timeout_error(canonical_path, offset));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run<T>(
|
||||
self,
|
||||
cancel_token: &CancellationToken,
|
||||
game_id: &str,
|
||||
canonical_path: &str,
|
||||
offset: u64,
|
||||
operation: impl Future<Output = T>,
|
||||
) -> DownloadTransferResult<T> {
|
||||
self.ensure_active(cancel_token, game_id, canonical_path, offset)?;
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
() = cancel_token.cancelled() => {
|
||||
return Err(DownloadTransferError::cancelled(game_id));
|
||||
}
|
||||
() = time::sleep_until(self.expires_at) => {
|
||||
return Err(self.timeout_error(canonical_path, offset));
|
||||
}
|
||||
result = operation => result,
|
||||
};
|
||||
self.ensure_active(cancel_token, game_id, canonical_path, offset)?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ReceiveBudget {
|
||||
expected: u64,
|
||||
@@ -40,29 +125,66 @@ impl ReceiveBudget {
|
||||
}
|
||||
}
|
||||
|
||||
fn accept(&mut self, byte_count: usize) -> eyre::Result<()> {
|
||||
let byte_count = u64::try_from(byte_count)?;
|
||||
self.received = self
|
||||
.received
|
||||
.checked_add(byte_count)
|
||||
.ok_or_else(|| eyre::eyre!("received chunk byte count overflow"))?;
|
||||
fn accept(&mut self, byte_count: usize) -> DownloadTransferResult<()> {
|
||||
let byte_count = u64::try_from(byte_count).map_err(|error| {
|
||||
DownloadTransferError::integrity(format!(
|
||||
"received chunk frame length does not fit u64: {error}"
|
||||
))
|
||||
})?;
|
||||
self.received = self.received.checked_add(byte_count).ok_or_else(|| {
|
||||
DownloadTransferError::integrity("received chunk byte count overflow")
|
||||
})?;
|
||||
if self.received > self.expected {
|
||||
eyre::bail!(
|
||||
return Err(DownloadTransferError::integrity(format!(
|
||||
"peer sent too many chunk bytes: expected {}, received at least {}",
|
||||
self.expected,
|
||||
self.received
|
||||
);
|
||||
self.expected, self.received
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(self) -> eyre::Result<()> {
|
||||
fn finish(self) -> DownloadTransferResult<()> {
|
||||
if self.received != self.expected {
|
||||
eyre::bail!(
|
||||
return Err(DownloadTransferError::integrity(format!(
|
||||
"incomplete chunk download: expected {} bytes, received {}",
|
||||
self.expected,
|
||||
self.received
|
||||
);
|
||||
self.expected, self.received
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ChunkVerifier {
|
||||
budget: ReceiveBudget,
|
||||
hasher: blake3::Hasher,
|
||||
expected_blake3: Blake3Digest,
|
||||
}
|
||||
|
||||
impl ChunkVerifier {
|
||||
fn new(expected_length: u64, expected_blake3: Blake3Digest) -> Self {
|
||||
Self {
|
||||
budget: ReceiveBudget::new(expected_length),
|
||||
hasher: blake3::Hasher::new(),
|
||||
expected_blake3,
|
||||
}
|
||||
}
|
||||
|
||||
fn accept(&mut self, bytes: &[u8]) -> DownloadTransferResult<()> {
|
||||
// Hash the complete peer frame even when it proves to exceed the
|
||||
// catalog-owned byte budget. No received byte bypasses verification.
|
||||
self.hasher.update(bytes);
|
||||
self.budget.accept(bytes.len())
|
||||
}
|
||||
|
||||
fn finish(self, path: &str, offset: u64) -> DownloadTransferResult<()> {
|
||||
self.budget.finish()?;
|
||||
let actual = Blake3Digest::from_bytes(*self.hasher.finalize().as_bytes());
|
||||
if actual != self.expected_blake3 {
|
||||
return Err(DownloadTransferError::integrity(format!(
|
||||
"catalog BLAKE3 mismatch for {path} at offset {offset}: expected {}, received {actual}",
|
||||
self.expected_blake3
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -73,59 +195,124 @@ async fn open_chunk_stream(
|
||||
game_id: &str,
|
||||
chunk: &DownloadChunk,
|
||||
cancel_token: &CancellationToken,
|
||||
) -> eyre::Result<ReceiveStream> {
|
||||
use lanspread_proto::{Message, Request};
|
||||
deadline: ChunkDeadline,
|
||||
) -> DownloadTransferResult<ReceiveStream> {
|
||||
let canonical_path = chunk.canonical_path();
|
||||
|
||||
let stream = tokio::select! {
|
||||
biased;
|
||||
() = cancel_token.cancelled() => {
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
result = conn.open_bidirectional_stream() => result?,
|
||||
};
|
||||
let stream = deadline
|
||||
.run(
|
||||
cancel_token,
|
||||
game_id,
|
||||
canonical_path.as_str(),
|
||||
chunk.offset,
|
||||
conn.open_bidirectional_stream(),
|
||||
)
|
||||
.await?
|
||||
.map_err(|error| {
|
||||
DownloadTransferError::transport(format!("failed to open chunk stream: {error}"))
|
||||
})?;
|
||||
let (rx, tx) = stream.split();
|
||||
let mut framed_tx = FramedWrite::new(tx, LengthDelimitedCodec::new());
|
||||
|
||||
let request = Request::GetGameFileChunk {
|
||||
game_id: game_id.to_string(),
|
||||
relative_path: chunk.request_path.clone(),
|
||||
content_id: chunk.content_id,
|
||||
relative_path: canonical_path.clone(),
|
||||
offset: chunk.offset,
|
||||
length: chunk.length,
|
||||
};
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel_token.cancelled() => {
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
result = framed_tx.send(request.encode()) => result?,
|
||||
}
|
||||
let encoded_request = request.encode().map_err(|error| {
|
||||
DownloadTransferError::transport(format!("failed to encode chunk request: {error}"))
|
||||
})?;
|
||||
deadline
|
||||
.run(
|
||||
cancel_token,
|
||||
game_id,
|
||||
canonical_path.as_str(),
|
||||
chunk.offset,
|
||||
framed_tx.send(encoded_request),
|
||||
)
|
||||
.await?
|
||||
.map_err(|error| {
|
||||
DownloadTransferError::transport(format!("failed to send chunk request: {error}"))
|
||||
})?;
|
||||
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel_token.cancelled() => {
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
result = framed_tx.close() => result?,
|
||||
}
|
||||
deadline
|
||||
.run(
|
||||
cancel_token,
|
||||
game_id,
|
||||
canonical_path.as_str(),
|
||||
chunk.offset,
|
||||
framed_tx.close(),
|
||||
)
|
||||
.await?
|
||||
.map_err(|error| {
|
||||
DownloadTransferError::transport(format!("failed to finish chunk request: {error}"))
|
||||
})?;
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// Receives one requested chunk from a peer stream.
|
||||
#[derive(Clone)]
|
||||
struct ChunkReceiveContext {
|
||||
peer_addr: SocketAddr,
|
||||
peer_endpoint: PeerEndpoint,
|
||||
game_root: ConfinedGameRoot,
|
||||
game_id: String,
|
||||
cancel_token: CancellationToken,
|
||||
version_buffer: Option<Arc<VersionIniBuffer>>,
|
||||
version_buffer: Arc<VersionIniBuffer>,
|
||||
progress_tracker: Arc<DownloadProgressTracker>,
|
||||
}
|
||||
|
||||
async fn flush_before_propagating<T>(
|
||||
writer: &mut (impl AsyncWrite + Unpin),
|
||||
operation_result: eyre::Result<T>,
|
||||
) -> eyre::Result<T> {
|
||||
let flush_result = writer.flush().await;
|
||||
fn ensure_chunk_active(
|
||||
deadline: ChunkDeadline,
|
||||
chunk: &DownloadChunk,
|
||||
ctx: &ChunkReceiveContext,
|
||||
) -> DownloadTransferResult<()> {
|
||||
deadline.ensure_active(
|
||||
&ctx.cancel_token,
|
||||
&ctx.game_id,
|
||||
chunk.canonical_path().as_str(),
|
||||
chunk.offset,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ChunkSink {
|
||||
RegularFile,
|
||||
VersionBuffer,
|
||||
}
|
||||
|
||||
fn select_chunk_sink(
|
||||
is_version_ini: bool,
|
||||
canonical_path: &str,
|
||||
version_buffer: &VersionIniBuffer,
|
||||
) -> DownloadTransferResult<ChunkSink> {
|
||||
match (is_version_ini, version_buffer.matches(canonical_path)) {
|
||||
(false, false) => Ok(ChunkSink::RegularFile),
|
||||
(true, true) => Ok(ChunkSink::VersionBuffer),
|
||||
_ => Err(DownloadTransferError::local_io(format!(
|
||||
"download plan and version.ini buffer disagree for {canonical_path}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn seek_chunk_file(file: &mut File, offset: u64) -> std::io::Result<()> {
|
||||
scoped_blocking(|| file.seek(SeekFrom::Start(offset)).map(|_| ()))
|
||||
}
|
||||
|
||||
/// Completes one bounded write in the caller's task so cancellation cannot
|
||||
/// detach filesystem work onto Tokio's blocking pool.
|
||||
fn write_chunk_bytes(writer: &mut impl std::io::Write, bytes: &[u8]) -> std::io::Result<()> {
|
||||
scoped_blocking(|| writer.write_all(bytes))
|
||||
}
|
||||
|
||||
fn flush_before_propagating<T>(
|
||||
writer: &mut impl std::io::Write,
|
||||
operation_result: DownloadTransferResult<T>,
|
||||
) -> DownloadTransferResult<T> {
|
||||
let flush_result = scoped_blocking(|| writer.flush()).map_err(|error| {
|
||||
DownloadTransferError::local_io(format!("failed to flush downloaded chunk: {error}"))
|
||||
});
|
||||
let value = operation_result?;
|
||||
flush_result?;
|
||||
Ok(value)
|
||||
@@ -135,51 +322,91 @@ async fn receive_chunk(
|
||||
mut rx: ReceiveStream,
|
||||
chunk: &DownloadChunk,
|
||||
ctx: &ChunkReceiveContext,
|
||||
) -> eyre::Result<()> {
|
||||
if let Some(buffer) = &ctx.version_buffer
|
||||
&& buffer.matches(&chunk.request_path)
|
||||
{
|
||||
return download_version_ini_chunk(rx, chunk, buffer, ctx).await;
|
||||
deadline: ChunkDeadline,
|
||||
) -> DownloadTransferResult<()> {
|
||||
ensure_chunk_active(deadline, chunk, ctx)?;
|
||||
match select_chunk_sink(
|
||||
chunk.is_version_ini(),
|
||||
chunk.canonical_path().as_str(),
|
||||
&ctx.version_buffer,
|
||||
)? {
|
||||
ChunkSink::VersionBuffer => {
|
||||
return download_version_ini_chunk(rx, chunk, &ctx.version_buffer, ctx, deadline).await;
|
||||
}
|
||||
ChunkSink::RegularFile => {}
|
||||
}
|
||||
|
||||
ensure_download_not_cancelled(&ctx.cancel_token, &ctx.game_id)?;
|
||||
let mut file =
|
||||
tokio::fs::File::from_std(ctx.game_root.open_chunk_file(&chunk.destination).await?);
|
||||
file.seek(std::io::SeekFrom::Start(chunk.offset)).await?;
|
||||
ensure_download_not_cancelled(&ctx.cancel_token, &ctx.game_id)?;
|
||||
let mut file = ctx
|
||||
.game_root
|
||||
.open_chunk_file(&chunk.destination)
|
||||
.map_err(|error| {
|
||||
DownloadTransferError::local_io(format!(
|
||||
"failed to open chunk destination {}: {error}",
|
||||
chunk.destination.canonical()
|
||||
))
|
||||
})?;
|
||||
ensure_chunk_active(deadline, chunk, ctx)?;
|
||||
seek_chunk_file(&mut file, chunk.offset).map_err(|error| {
|
||||
DownloadTransferError::local_io(format!(
|
||||
"failed to seek chunk destination {} to offset {}: {error}",
|
||||
chunk.destination.canonical(),
|
||||
chunk.offset
|
||||
))
|
||||
})?;
|
||||
ensure_chunk_active(deadline, chunk, ctx)?;
|
||||
|
||||
let mut receive_budget = ReceiveBudget::new(chunk.length);
|
||||
let mut verifier = ChunkVerifier::new(chunk.length, chunk.expected_blake3());
|
||||
let mut progress = ctx.progress_tracker.track_chunk(
|
||||
ctx.peer_addr,
|
||||
&chunk.request_path,
|
||||
ctx.peer_endpoint,
|
||||
chunk.content_id,
|
||||
chunk.canonical_path(),
|
||||
chunk.offset,
|
||||
chunk.length,
|
||||
);
|
||||
|
||||
let receive_result: eyre::Result<()> = async {
|
||||
let receive_result: DownloadTransferResult<()> = async {
|
||||
loop {
|
||||
let bytes = tokio::select! {
|
||||
biased;
|
||||
() = ctx.cancel_token.cancelled() => {
|
||||
eyre::bail!("download cancelled for game {}", ctx.game_id);
|
||||
}
|
||||
result = rx.receive() => result?,
|
||||
};
|
||||
let bytes = deadline
|
||||
.run(
|
||||
&ctx.cancel_token,
|
||||
&ctx.game_id,
|
||||
chunk.canonical_path().as_str(),
|
||||
chunk.offset,
|
||||
rx.receive(),
|
||||
)
|
||||
.await?
|
||||
.map_err(|error| {
|
||||
DownloadTransferError::transport(format!(
|
||||
"failed to receive chunk {} at offset {}: {error}",
|
||||
chunk.canonical_path().as_str(),
|
||||
chunk.offset
|
||||
))
|
||||
})?;
|
||||
let Some(bytes) = bytes else {
|
||||
break;
|
||||
};
|
||||
receive_budget.accept(bytes.len())?;
|
||||
file.write_all(&bytes).await?;
|
||||
verifier.accept(&bytes)?;
|
||||
ensure_chunk_active(deadline, chunk, ctx)?;
|
||||
write_chunk_bytes(&mut file, &bytes).map_err(|error| {
|
||||
DownloadTransferError::local_io(format!(
|
||||
"failed to write chunk destination {} at offset {}: {error}",
|
||||
chunk.destination.canonical(),
|
||||
chunk.offset
|
||||
))
|
||||
})?;
|
||||
progress.record_bytes(bytes.len());
|
||||
ensure_chunk_active(deadline, chunk, ctx)?;
|
||||
}
|
||||
receive_budget.finish()
|
||||
verifier.finish(chunk.canonical_path().as_str(), chunk.offset)
|
||||
}
|
||||
.await;
|
||||
|
||||
flush_before_propagating(&mut file, receive_result).await?;
|
||||
flush_before_propagating(&mut file, receive_result)?;
|
||||
ensure_chunk_active(deadline, chunk, ctx)?;
|
||||
|
||||
// Verify file integrity by checking the file size
|
||||
verify_chunk_integrity(&file, chunk.offset, chunk.length).await?;
|
||||
verify_chunk_integrity(&file, chunk.offset, chunk.length)?;
|
||||
ensure_chunk_active(deadline, chunk, ctx)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -188,12 +415,13 @@ async fn receive_chunk_result(
|
||||
chunk: DownloadChunk,
|
||||
rx: ReceiveStream,
|
||||
ctx: ChunkReceiveContext,
|
||||
deadline: ChunkDeadline,
|
||||
) -> ChunkDownloadResult {
|
||||
let result = receive_chunk(rx, &chunk, &ctx).await;
|
||||
let result = receive_chunk(rx, &chunk, &ctx, deadline).await;
|
||||
ChunkDownloadResult {
|
||||
chunk,
|
||||
result,
|
||||
peer_addr: ctx.peer_addr,
|
||||
peer_endpoint: ctx.peer_endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,55 +430,88 @@ async fn download_version_ini_chunk(
|
||||
chunk: &DownloadChunk,
|
||||
buffer: &VersionIniBuffer,
|
||||
ctx: &ChunkReceiveContext,
|
||||
) -> eyre::Result<()> {
|
||||
let mut received = Vec::with_capacity(usize::try_from(chunk.length)?);
|
||||
let mut receive_budget = ReceiveBudget::new(chunk.length);
|
||||
deadline: ChunkDeadline,
|
||||
) -> DownloadTransferResult<()> {
|
||||
ensure_chunk_active(deadline, chunk, ctx)?;
|
||||
let capacity = usize::try_from(chunk.length).map_err(|error| {
|
||||
DownloadTransferError::local_io(format!(
|
||||
"version.ini chunk length does not fit local memory: {error}"
|
||||
))
|
||||
})?;
|
||||
let mut received = Vec::with_capacity(capacity);
|
||||
let mut verifier = ChunkVerifier::new(chunk.length, chunk.expected_blake3());
|
||||
let mut progress = ctx.progress_tracker.track_chunk(
|
||||
ctx.peer_addr,
|
||||
&chunk.request_path,
|
||||
ctx.peer_endpoint,
|
||||
chunk.content_id,
|
||||
chunk.canonical_path(),
|
||||
chunk.offset,
|
||||
chunk.length,
|
||||
);
|
||||
loop {
|
||||
let bytes = tokio::select! {
|
||||
biased;
|
||||
() = ctx.cancel_token.cancelled() => {
|
||||
eyre::bail!("download cancelled for game {}", ctx.game_id);
|
||||
}
|
||||
result = rx.receive() => result?,
|
||||
};
|
||||
let bytes = deadline
|
||||
.run(
|
||||
&ctx.cancel_token,
|
||||
&ctx.game_id,
|
||||
chunk.canonical_path().as_str(),
|
||||
chunk.offset,
|
||||
rx.receive(),
|
||||
)
|
||||
.await?
|
||||
.map_err(|error| {
|
||||
DownloadTransferError::transport(format!(
|
||||
"failed to receive buffered version.ini chunk at offset {}: {error}",
|
||||
chunk.offset
|
||||
))
|
||||
})?;
|
||||
let Some(bytes) = bytes else {
|
||||
break;
|
||||
};
|
||||
receive_budget.accept(bytes.len())?;
|
||||
verifier.accept(&bytes)?;
|
||||
progress.record_bytes(bytes.len());
|
||||
received.extend_from_slice(&bytes);
|
||||
}
|
||||
receive_budget.finish()?;
|
||||
|
||||
buffer.write_at(chunk.offset, &received).await
|
||||
verifier.finish(chunk.canonical_path().as_str(), chunk.offset)?;
|
||||
deadline
|
||||
.run(
|
||||
&ctx.cancel_token,
|
||||
&ctx.game_id,
|
||||
chunk.canonical_path().as_str(),
|
||||
chunk.offset,
|
||||
buffer.write_at(chunk.offset, &received),
|
||||
)
|
||||
.await?
|
||||
.map_err(|error| {
|
||||
DownloadTransferError::local_io(format!(
|
||||
"failed to buffer version.ini chunk at offset {offset}: {error}",
|
||||
offset = chunk.offset
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Verifies that a chunk was written correctly.
|
||||
async fn verify_chunk_integrity(
|
||||
file: &tokio::fs::File,
|
||||
fn verify_chunk_integrity(
|
||||
file: &File,
|
||||
offset: u64,
|
||||
expected_length: u64,
|
||||
) -> eyre::Result<()> {
|
||||
) -> DownloadTransferResult<()> {
|
||||
if expected_length == 0 {
|
||||
return Ok(()); // Skip verification for whole files or zero-length chunks
|
||||
}
|
||||
|
||||
let metadata = file.metadata().await?;
|
||||
let metadata = scoped_blocking(|| file.metadata()).map_err(|error| {
|
||||
DownloadTransferError::local_io(format!(
|
||||
"failed to inspect downloaded chunk destination: {error}"
|
||||
))
|
||||
})?;
|
||||
let file_size = metadata.len();
|
||||
let expected_end = offset
|
||||
.checked_add(expected_length)
|
||||
.ok_or_else(|| DownloadTransferError::local_io("chunk end offset overflow"))?;
|
||||
|
||||
if file_size < offset + expected_length {
|
||||
eyre::bail!(
|
||||
"File integrity check failed: file size {} is less than expected {} (offset: {})",
|
||||
file_size,
|
||||
offset + expected_length,
|
||||
offset
|
||||
);
|
||||
if file_size < expected_end {
|
||||
return Err(DownloadTransferError::local_io(format!(
|
||||
"file integrity check failed: file size {file_size} is less than expected {expected_end} (offset: {offset})"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -258,34 +519,79 @@ async fn verify_chunk_integrity(
|
||||
|
||||
fn failed_chunk_result(
|
||||
chunk: DownloadChunk,
|
||||
peer_addr: SocketAddr,
|
||||
reason: impl Into<String>,
|
||||
peer_endpoint: PeerEndpoint,
|
||||
error: DownloadTransferError,
|
||||
) -> ChunkDownloadResult {
|
||||
ChunkDownloadResult {
|
||||
chunk,
|
||||
result: Err(eyre::Report::msg(reason.into())),
|
||||
peer_addr,
|
||||
result: Err(error),
|
||||
peer_endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn failed_plan_results(
|
||||
plan: PeerDownloadPlan,
|
||||
peer_addr: SocketAddr,
|
||||
peer_endpoint: PeerEndpoint,
|
||||
reason: impl std::fmt::Display,
|
||||
) -> Vec<ChunkDownloadResult> {
|
||||
let reason = format!("peer connection failed: {reason}");
|
||||
plan.chunks
|
||||
.into_iter()
|
||||
.map(|chunk| failed_chunk_result(chunk, peer_addr, reason.clone()))
|
||||
.map(|chunk| {
|
||||
failed_chunk_result(
|
||||
chunk,
|
||||
peer_endpoint,
|
||||
DownloadTransferError::transport(reason.clone()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn record_completed_chunk(
|
||||
completed: ChunkDownloadResult,
|
||||
pending: &mut VecDeque<DownloadChunk>,
|
||||
results: &mut Vec<ChunkDownloadResult>,
|
||||
peer_endpoint: PeerEndpoint,
|
||||
) {
|
||||
let stop_reason = completed.result.as_ref().err().and_then(|error| {
|
||||
matches!(
|
||||
error.kind(),
|
||||
DownloadTransferErrorKind::Integrity | DownloadTransferErrorKind::Transport
|
||||
)
|
||||
.then(|| error.to_string())
|
||||
});
|
||||
results.push(completed);
|
||||
|
||||
let Some(stop_reason) = stop_reason else {
|
||||
return;
|
||||
};
|
||||
while let Some(chunk) = pending.pop_front() {
|
||||
results.push(failed_chunk_result(
|
||||
chunk,
|
||||
peer_endpoint,
|
||||
DownloadTransferError::transport(format!(
|
||||
"source unavailable after earlier chunk failure: {stop_reason}"
|
||||
)),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn take_pending_chunk_for_window(
|
||||
pending: &mut VecDeque<DownloadChunk>,
|
||||
in_flight: usize,
|
||||
) -> Option<DownloadChunk> {
|
||||
if in_flight >= PEER_DOWNLOAD_STREAM_WINDOW.max(1) {
|
||||
return None;
|
||||
}
|
||||
pending.pop_front()
|
||||
}
|
||||
|
||||
struct ChunkPlanContext<'a> {
|
||||
peer_addr: SocketAddr,
|
||||
peer_endpoint: PeerEndpoint,
|
||||
game_id: &'a str,
|
||||
game_root: &'a ConfinedGameRoot,
|
||||
cancel_token: &'a CancellationToken,
|
||||
version_buffer: Option<Arc<VersionIniBuffer>>,
|
||||
version_buffer: Arc<VersionIniBuffer>,
|
||||
progress_tracker: Arc<DownloadProgressTracker>,
|
||||
}
|
||||
|
||||
@@ -293,13 +599,12 @@ async fn download_chunk_plan(
|
||||
conn: &mut Connection,
|
||||
chunks: Vec<DownloadChunk>,
|
||||
ctx: &ChunkPlanContext<'_>,
|
||||
) -> eyre::Result<Vec<ChunkDownloadResult>> {
|
||||
) -> DownloadTransferResult<Vec<ChunkDownloadResult>> {
|
||||
let mut pending: VecDeque<DownloadChunk> = chunks.into();
|
||||
let mut in_flight = FuturesUnordered::new();
|
||||
let mut results = Vec::new();
|
||||
let window = PEER_DOWNLOAD_STREAM_WINDOW.max(1);
|
||||
let receive_ctx = ChunkReceiveContext {
|
||||
peer_addr: ctx.peer_addr,
|
||||
peer_endpoint: ctx.peer_endpoint,
|
||||
game_root: ctx.game_root.clone(),
|
||||
game_id: ctx.game_id.to_owned(),
|
||||
cancel_token: ctx.cancel_token.clone(),
|
||||
@@ -314,36 +619,44 @@ async fn download_chunk_plan(
|
||||
results.clear();
|
||||
}
|
||||
|
||||
while !cancelled && in_flight.len() < window {
|
||||
let Some(chunk) = pending.pop_front() else {
|
||||
while !cancelled {
|
||||
let Some(chunk) = take_pending_chunk_for_window(&mut pending, in_flight.len()) else {
|
||||
break;
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Downloading chunk {} (offset {}, length {}) from {}",
|
||||
chunk.request_path,
|
||||
chunk.canonical_path().as_str(),
|
||||
chunk.offset,
|
||||
chunk.length,
|
||||
ctx.peer_addr
|
||||
ctx.peer_endpoint.addr
|
||||
);
|
||||
|
||||
match open_chunk_stream(conn, ctx.game_id, &chunk, ctx.cancel_token).await {
|
||||
let deadline = ChunkDeadline::ordinary();
|
||||
match open_chunk_stream(conn, ctx.game_id, &chunk, ctx.cancel_token, deadline).await {
|
||||
Ok(rx) => {
|
||||
in_flight.push(receive_chunk_result(chunk, rx, receive_ctx.clone()));
|
||||
in_flight.push(receive_chunk_result(
|
||||
chunk,
|
||||
rx,
|
||||
receive_ctx.clone(),
|
||||
deadline,
|
||||
));
|
||||
}
|
||||
Err(_) if ctx.cancel_token.is_cancelled() => {
|
||||
Err(error) if error.kind() == DownloadTransferErrorKind::Cancelled => {
|
||||
cancelled = true;
|
||||
results.clear();
|
||||
break;
|
||||
}
|
||||
Err(err) => {
|
||||
let reason = format!("failed to open chunk stream: {err}");
|
||||
results.push(failed_chunk_result(chunk, ctx.peer_addr, reason.clone()));
|
||||
let reason = err.to_string();
|
||||
results.push(failed_chunk_result(chunk, ctx.peer_endpoint, err));
|
||||
while let Some(chunk) = pending.pop_front() {
|
||||
results.push(failed_chunk_result(
|
||||
chunk,
|
||||
ctx.peer_addr,
|
||||
format!("peer stream unavailable after earlier open failure: {reason}"),
|
||||
ctx.peer_endpoint,
|
||||
DownloadTransferError::transport(format!(
|
||||
"peer stream unavailable after earlier open failure: {reason}"
|
||||
)),
|
||||
));
|
||||
}
|
||||
break;
|
||||
@@ -373,53 +686,71 @@ async fn download_chunk_plan(
|
||||
results.clear();
|
||||
}
|
||||
result = in_flight.next() => {
|
||||
results.push(result.expect("in-flight chunk stream should exist"));
|
||||
record_completed_chunk(
|
||||
result.expect("in-flight chunk stream should exist"),
|
||||
&mut pending,
|
||||
&mut results,
|
||||
ctx.peer_endpoint,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if cancelled {
|
||||
eyre::bail!("download cancelled for game {}", ctx.game_id);
|
||||
return Err(DownloadTransferError::cancelled(ctx.game_id));
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Downloads all assigned chunks and files from a single peer.
|
||||
pub(super) struct PeerDownloadRequest {
|
||||
pub(super) quic: QuicConnector,
|
||||
pub(super) endpoint: PeerEndpoint,
|
||||
pub(super) game_id: String,
|
||||
pub(super) plan: PeerDownloadPlan,
|
||||
pub(super) game_root: ConfinedGameRoot,
|
||||
pub(super) cancel_token: CancellationToken,
|
||||
pub(super) version_buffer: Arc<VersionIniBuffer>,
|
||||
pub(super) progress_tracker: Arc<DownloadProgressTracker>,
|
||||
}
|
||||
|
||||
pub(super) async fn download_from_peer(
|
||||
peer_addr: SocketAddr,
|
||||
game_id: &str,
|
||||
plan: PeerDownloadPlan,
|
||||
game_root: ConfinedGameRoot,
|
||||
cancel_token: &CancellationToken,
|
||||
version_buffer: Option<Arc<VersionIniBuffer>>,
|
||||
progress_tracker: Arc<DownloadProgressTracker>,
|
||||
) -> eyre::Result<Vec<ChunkDownloadResult>> {
|
||||
request: PeerDownloadRequest,
|
||||
) -> DownloadTransferResult<Vec<ChunkDownloadResult>> {
|
||||
let PeerDownloadRequest {
|
||||
quic,
|
||||
endpoint,
|
||||
game_id,
|
||||
plan,
|
||||
game_root,
|
||||
cancel_token,
|
||||
version_buffer,
|
||||
progress_tracker,
|
||||
} = request;
|
||||
if plan.chunks.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
ensure_download_not_cancelled(cancel_token, game_id)?;
|
||||
ensure_download_not_cancelled(&cancel_token, &game_id)?;
|
||||
|
||||
let mut conn = match tokio::select! {
|
||||
() = cancel_token.cancelled() => {
|
||||
eyre::bail!("download cancelled for game {game_id}");
|
||||
}
|
||||
result = connect_to_peer(peer_addr) => result,
|
||||
} {
|
||||
let mut conn = match connect_to_peer(&quic, &endpoint, &cancel_token).await {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => return Ok(failed_plan_results(plan, peer_addr, err)),
|
||||
Err(_) if cancel_token.is_cancelled() => {
|
||||
return Err(DownloadTransferError::cancelled(&game_id));
|
||||
}
|
||||
Err(err) => return Ok(failed_plan_results(plan, endpoint, err)),
|
||||
};
|
||||
|
||||
if let Err(err) = conn.keep_alive(true) {
|
||||
return Ok(failed_plan_results(plan, peer_addr, err));
|
||||
return Ok(failed_plan_results(plan, endpoint, err));
|
||||
}
|
||||
|
||||
let chunk_ctx = ChunkPlanContext {
|
||||
peer_addr,
|
||||
game_id,
|
||||
peer_endpoint: endpoint,
|
||||
game_id: &game_id,
|
||||
game_root: &game_root,
|
||||
cancel_token,
|
||||
cancel_token: &cancel_token,
|
||||
version_buffer,
|
||||
progress_tracker,
|
||||
};
|
||||
@@ -432,39 +763,144 @@ pub(super) async fn download_from_peer(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
io,
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
collections::VecDeque,
|
||||
future,
|
||||
io::{self, Write},
|
||||
net::SocketAddr,
|
||||
sync::{Arc, mpsc},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tokio::io::AsyncWrite;
|
||||
use lanspread_db::content_manifest::{
|
||||
Blake3Digest,
|
||||
CATALOG_CHUNK_SIZE,
|
||||
CatalogContentManifest,
|
||||
CatalogContentManifestBody,
|
||||
CatalogFileEntry,
|
||||
};
|
||||
use lanspread_proto::{PeerEndpoint, PeerId};
|
||||
|
||||
use super::{ReceiveBudget, flush_before_propagating};
|
||||
use super::{
|
||||
CancellationToken,
|
||||
ChunkDeadline,
|
||||
ChunkDownloadResult,
|
||||
ChunkSink,
|
||||
ChunkVerifier,
|
||||
DownloadChunk,
|
||||
DownloadTransferError,
|
||||
DownloadTransferErrorKind,
|
||||
DownloadTransferResult,
|
||||
ReceiveBudget,
|
||||
VersionIniBuffer,
|
||||
flush_before_propagating,
|
||||
record_completed_chunk,
|
||||
select_chunk_sink,
|
||||
take_pending_chunk_for_window,
|
||||
write_chunk_bytes,
|
||||
};
|
||||
use crate::{
|
||||
config::PEER_DOWNLOAD_STREAM_WINDOW,
|
||||
download::{ValidatedDownloadManifest, planning::build_peer_plans},
|
||||
test_support::TempDir,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct FlushProbe {
|
||||
flushed: bool,
|
||||
}
|
||||
|
||||
impl AsyncWrite for FlushProbe {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
_context: &mut Context<'_>,
|
||||
bytes: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
Poll::Ready(Ok(bytes.len()))
|
||||
impl Write for FlushProbe {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn poll_flush(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
self.get_mut().flushed = true;
|
||||
Poll::Ready(Ok(()))
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.flushed = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct DelayedWriter {
|
||||
started: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
release: mpsc::Receiver<()>,
|
||||
}
|
||||
|
||||
impl Write for DelayedWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
if let Some(started) = self.started.take() {
|
||||
let _ = started.send(());
|
||||
}
|
||||
self.release
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.map_err(io::Error::other)?;
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn loopback_addr(port: u16) -> SocketAddr {
|
||||
SocketAddr::from(([127, 0, 0, 1], port))
|
||||
}
|
||||
|
||||
fn endpoint_for_addr(peer: SocketAddr) -> PeerEndpoint {
|
||||
PeerEndpoint::new(
|
||||
PeerId::from_bytes(*blake3::hash(peer.to_string().as_bytes()).as_bytes()),
|
||||
peer,
|
||||
)
|
||||
}
|
||||
|
||||
fn catalog_planned_chunks(peer: SocketAddr) -> Vec<DownloadChunk> {
|
||||
let endpoint = endpoint_for_addr(peer);
|
||||
let archive_chunk_count = PEER_DOWNLOAD_STREAM_WINDOW.max(1) + 2;
|
||||
let archive_digests = (0..archive_chunk_count)
|
||||
.map(|index| {
|
||||
let byte = u8::try_from(index + 1).expect("test chunk count should fit in u8");
|
||||
Blake3Digest::from_bytes([byte; 32])
|
||||
})
|
||||
.collect();
|
||||
let version_digest = Blake3Digest::hash(b"1");
|
||||
let catalog = Arc::new(
|
||||
CatalogContentManifest::seal(
|
||||
CatalogContentManifestBody::new(
|
||||
"game",
|
||||
"1",
|
||||
vec![
|
||||
CatalogFileEntry::file(
|
||||
"archive.eti",
|
||||
CATALOG_CHUNK_SIZE
|
||||
* u64::try_from(archive_chunk_count)
|
||||
.expect("test chunk count should fit in u64"),
|
||||
Blake3Digest::from_bytes([0xf0; 32]),
|
||||
archive_digests,
|
||||
)
|
||||
.expect("multi-chunk archive should validate"),
|
||||
CatalogFileEntry::file(
|
||||
"version.ini",
|
||||
1,
|
||||
version_digest,
|
||||
vec![version_digest],
|
||||
)
|
||||
.expect("version.ini should validate"),
|
||||
],
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("catalog body should validate"),
|
||||
)
|
||||
.expect("catalog should seal"),
|
||||
);
|
||||
let temp = TempDir::new("lanspread-transport-plan");
|
||||
let manifest = ValidatedDownloadManifest::from_catalog(temp.path(), catalog)
|
||||
.expect("catalog download manifest should validate");
|
||||
build_peer_plans(&[endpoint], &manifest)
|
||||
.expect("catalog plan should build")
|
||||
.remove(&endpoint)
|
||||
.expect("peer should receive a plan")
|
||||
.chunks
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_budget_accepts_exactly_the_requested_bytes() {
|
||||
let mut budget = ReceiveBudget::new(5);
|
||||
@@ -490,16 +926,306 @@ mod tests {
|
||||
.expect("empty zero-length stream should finish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_verifier_hashes_split_frames_against_catalog_digest() {
|
||||
let expected = Blake3Digest::hash(b"catalog bytes");
|
||||
let mut verifier = ChunkVerifier::new(13, expected);
|
||||
|
||||
verifier
|
||||
.accept(b"catalog ")
|
||||
.expect("first frame should fit");
|
||||
verifier.accept(b"bytes").expect("second frame should fit");
|
||||
|
||||
verifier
|
||||
.finish("archive.eti", 0)
|
||||
.expect("byte count and streaming digest should match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunk_verifier_classifies_wrong_short_and_extra_bytes_as_integrity() {
|
||||
let expected = Blake3Digest::hash(b"right");
|
||||
|
||||
let mut wrong = ChunkVerifier::new(5, expected);
|
||||
wrong
|
||||
.accept(b"wrong")
|
||||
.expect("wrong bytes have exact length");
|
||||
assert_eq!(
|
||||
wrong
|
||||
.finish("archive.eti", 0)
|
||||
.expect_err("wrong digest must fail")
|
||||
.kind(),
|
||||
DownloadTransferErrorKind::Integrity
|
||||
);
|
||||
|
||||
let mut short = ChunkVerifier::new(5, expected);
|
||||
short.accept(b"righ").expect("short frame should fit");
|
||||
assert_eq!(
|
||||
short
|
||||
.finish("archive.eti", 0)
|
||||
.expect_err("short payload must fail")
|
||||
.kind(),
|
||||
DownloadTransferErrorKind::Integrity
|
||||
);
|
||||
|
||||
let mut extra = ChunkVerifier::new(5, expected);
|
||||
assert_eq!(
|
||||
extra
|
||||
.accept(b"right!")
|
||||
.expect_err("extra payload must fail immediately")
|
||||
.kind(),
|
||||
DownloadTransferErrorKind::Integrity
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_byte_chunk_still_verifies_the_catalog_file_digest() {
|
||||
ChunkVerifier::new(0, Blake3Digest::hash(&[]))
|
||||
.finish("empty.bin", 0)
|
||||
.expect("empty catalog file should verify");
|
||||
|
||||
assert_eq!(
|
||||
ChunkVerifier::new(0, Blake3Digest::from_bytes([7; 32]))
|
||||
.finish("empty.bin", 0)
|
||||
.expect_err("incorrect empty-file digest must fail")
|
||||
.kind(),
|
||||
DownloadTransferErrorKind::Integrity
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_ini_can_never_fall_through_to_the_regular_file_sink() {
|
||||
let buffer =
|
||||
VersionIniBuffer::new("version.ini", 8).expect("version.ini buffer should validate");
|
||||
|
||||
assert_eq!(
|
||||
select_chunk_sink(true, "version.ini", &buffer)
|
||||
.expect("matching sentinel chunk should route"),
|
||||
ChunkSink::VersionBuffer
|
||||
);
|
||||
assert_eq!(
|
||||
select_chunk_sink(false, "archive.eti", &buffer).expect("ordinary chunk should route"),
|
||||
ChunkSink::RegularFile
|
||||
);
|
||||
for (is_version_ini, path) in [(true, "archive.eti"), (false, "version.ini")] {
|
||||
assert_eq!(
|
||||
select_chunk_sink(is_version_ini, path, &buffer)
|
||||
.expect_err("mismatched local routing state must fail closed")
|
||||
.kind(),
|
||||
DownloadTransferErrorKind::LocalIo
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn receive_errors_are_propagated_only_after_flushing() {
|
||||
async fn injected_app_deadline_expires_while_the_transport_future_stays_live() {
|
||||
let cancellation = CancellationToken::new();
|
||||
let deadline = ChunkDeadline::after(Duration::from_millis(25));
|
||||
let transfer = deadline.run(
|
||||
&cancellation,
|
||||
"game",
|
||||
"archive.eti",
|
||||
0,
|
||||
future::pending::<()>(),
|
||||
);
|
||||
|
||||
let error = tokio::time::timeout(Duration::from_secs(2), transfer)
|
||||
.await
|
||||
.expect("injected application deadline should be bounded")
|
||||
.expect_err("the application deadline must defeat a live QUIC transport");
|
||||
assert_eq!(error.kind(), DownloadTransferErrorKind::Transport);
|
||||
assert!(
|
||||
error.to_string().contains("timed out after 25ms"),
|
||||
"timeout diagnostic should preserve the injected deadline"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrity_failure_stops_pending_work_but_drains_the_controlled_window() {
|
||||
let peer = loopback_addr(12030);
|
||||
let endpoint = endpoint_for_addr(peer);
|
||||
let mut pending = VecDeque::from(catalog_planned_chunks(peer));
|
||||
let total_chunks = pending.len();
|
||||
let mut in_flight = Vec::new();
|
||||
while let Some(chunk) = take_pending_chunk_for_window(&mut pending, in_flight.len()) {
|
||||
in_flight.push(chunk);
|
||||
}
|
||||
assert_eq!(
|
||||
in_flight.len(),
|
||||
PEER_DOWNLOAD_STREAM_WINDOW.max(1),
|
||||
"the production scheduler must open only one controlled window"
|
||||
);
|
||||
assert!(
|
||||
!pending.is_empty(),
|
||||
"the test plan must contain work beyond the controlled window"
|
||||
);
|
||||
|
||||
let never_started = pending.len();
|
||||
let bad = in_flight.remove(0);
|
||||
let mut results = Vec::new();
|
||||
|
||||
record_completed_chunk(
|
||||
ChunkDownloadResult {
|
||||
chunk: bad,
|
||||
result: Err(DownloadTransferError::integrity("wrong catalog bytes")),
|
||||
peer_endpoint: endpoint,
|
||||
},
|
||||
&mut pending,
|
||||
&mut results,
|
||||
endpoint,
|
||||
);
|
||||
|
||||
assert!(pending.is_empty(), "no new stream may open after bad bytes");
|
||||
assert!(
|
||||
take_pending_chunk_for_window(&mut pending, in_flight.len()).is_none(),
|
||||
"the production scheduler must not open another stream after bad bytes"
|
||||
);
|
||||
assert_eq!(
|
||||
results.len(),
|
||||
never_started + 1,
|
||||
"bad and never-started chunks are returned"
|
||||
);
|
||||
assert_eq!(
|
||||
results
|
||||
.iter()
|
||||
.filter(|result| {
|
||||
result
|
||||
.result
|
||||
.as_ref()
|
||||
.is_err_and(|error| error.kind() == DownloadTransferErrorKind::Integrity)
|
||||
})
|
||||
.count(),
|
||||
1,
|
||||
"only the chunk that supplied invalid bytes may prove integrity failure"
|
||||
);
|
||||
assert_eq!(
|
||||
results
|
||||
.iter()
|
||||
.filter(|result| {
|
||||
result
|
||||
.result
|
||||
.as_ref()
|
||||
.is_err_and(|error| error.kind() == DownloadTransferErrorKind::Transport)
|
||||
})
|
||||
.count(),
|
||||
never_started,
|
||||
"never-started chunks must stay retryable without false quarantine evidence"
|
||||
);
|
||||
|
||||
let already_in_flight = in_flight.len();
|
||||
for chunk in in_flight {
|
||||
record_completed_chunk(
|
||||
ChunkDownloadResult {
|
||||
chunk,
|
||||
result: Ok(()),
|
||||
peer_endpoint: endpoint,
|
||||
},
|
||||
&mut pending,
|
||||
&mut results,
|
||||
endpoint,
|
||||
);
|
||||
}
|
||||
assert_eq!(results.len(), total_chunks);
|
||||
assert_eq!(
|
||||
results
|
||||
.iter()
|
||||
.filter(|result| result.result.is_ok())
|
||||
.count(),
|
||||
already_in_flight,
|
||||
"every already-open stream must be drained and retained"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn receive_errors_are_propagated_only_after_flushing() {
|
||||
let mut probe = FlushProbe::default();
|
||||
let receive_error: eyre::Result<()> = Err(eyre::eyre!("peer receive failed"));
|
||||
let receive_error: DownloadTransferResult<()> =
|
||||
Err(DownloadTransferError::transport("peer receive failed"));
|
||||
|
||||
let error = flush_before_propagating(&mut probe, receive_error)
|
||||
.await
|
||||
.expect_err("receive error should be preserved");
|
||||
|
||||
assert!(probe.flushed);
|
||||
assert_eq!(error.to_string(), "peer receive failed");
|
||||
assert_eq!(error.kind(), DownloadTransferErrorKind::Transport);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn abort_waits_for_started_chunk_write_to_settle() {
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
let mut task = tokio::spawn(async move {
|
||||
let mut writer = DelayedWriter {
|
||||
started: Some(started_tx),
|
||||
release: release_rx,
|
||||
};
|
||||
write_chunk_bytes(&mut writer, b"payload")
|
||||
});
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(2), started_rx)
|
||||
.await
|
||||
.expect("chunk write should start")
|
||||
.expect("chunk writer should retain the start signal");
|
||||
|
||||
task.abort();
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(50), &mut task)
|
||||
.await
|
||||
.is_err(),
|
||||
"aborting must not complete the task while its file write is still running"
|
||||
);
|
||||
|
||||
release_tx
|
||||
.send(())
|
||||
.expect("delayed chunk write should still be waiting");
|
||||
let completion = tokio::time::timeout(Duration::from_secs(2), &mut task)
|
||||
.await
|
||||
.expect("chunk task should finish after the write settles");
|
||||
match completion {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => panic!("chunk write failed unexpectedly: {error}"),
|
||||
Err(error) if error.is_cancelled() => {}
|
||||
Err(error) => panic!("chunk task failed unexpectedly: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn deadline_waits_for_started_scoped_file_work_to_settle() {
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
let mut task = tokio::spawn(async move {
|
||||
let cancellation = CancellationToken::new();
|
||||
let deadline = ChunkDeadline::after(Duration::from_millis(25));
|
||||
let mut writer = DelayedWriter {
|
||||
started: Some(started_tx),
|
||||
release: release_rx,
|
||||
};
|
||||
deadline
|
||||
.run(&cancellation, "game", "archive.eti", 0, async {
|
||||
write_chunk_bytes(&mut writer, b"payload")
|
||||
})
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(2), started_rx)
|
||||
.await
|
||||
.expect("chunk write should start")
|
||||
.expect("chunk writer should retain the start signal");
|
||||
tokio::time::sleep(Duration::from_millis(75)).await;
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(25), &mut task)
|
||||
.await
|
||||
.is_err(),
|
||||
"deadline must not report completion while scoped file work is running"
|
||||
);
|
||||
|
||||
release_tx
|
||||
.send(())
|
||||
.expect("delayed chunk write should still be waiting");
|
||||
let error = tokio::time::timeout(Duration::from_secs(2), &mut task)
|
||||
.await
|
||||
.expect("deadline task should finish after file work settles")
|
||||
.expect("deadline task should not panic")
|
||||
.expect_err("elapsed deadline should fail after file work settles");
|
||||
assert_eq!(error.kind(), DownloadTransferErrorKind::Transport);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
use tokio::{io::AsyncWriteExt, sync::Mutex};
|
||||
use std::{fs::File, io::Write as _};
|
||||
|
||||
use tokio::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},
|
||||
scoped_blocking::scoped_blocking,
|
||||
};
|
||||
|
||||
pub(super) enum VersionIniCommit {
|
||||
Durable,
|
||||
@@ -50,28 +55,20 @@ impl VersionIniBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn begin_version_ini_transaction(
|
||||
game_root: &ConfinedGameRoot,
|
||||
) -> eyre::Result<()> {
|
||||
game_root
|
||||
.remove_root_file_if_exists(VERSION_TMP_FILE)
|
||||
.await?;
|
||||
game_root
|
||||
.remove_root_file_if_exists(VERSION_DISCARDED_FILE)
|
||||
.await?;
|
||||
pub(super) fn begin_version_ini_transaction(game_root: &ConfinedGameRoot) -> eyre::Result<()> {
|
||||
game_root.remove_root_file_if_exists(VERSION_TMP_FILE)?;
|
||||
game_root.remove_root_file_if_exists(VERSION_DISCARDED_FILE)?;
|
||||
|
||||
if game_root.root_regular_file_exists(VERSION_INI).await? {
|
||||
game_root
|
||||
.rename_root_file(VERSION_INI, VERSION_DISCARDED_FILE)
|
||||
.await?;
|
||||
game_root.sync_root().await?;
|
||||
if game_root.root_regular_file_exists(VERSION_INI)? {
|
||||
game_root.rename_root_file(VERSION_INI, VERSION_DISCARDED_FILE)?;
|
||||
game_root.sync_root()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) async fn rollback_version_ini_transaction(game_root: &ConfinedGameRoot) {
|
||||
if let Err(err) = discard_version_ini_transaction(game_root).await {
|
||||
pub(super) fn rollback_version_ini_transaction(game_root: &ConfinedGameRoot) {
|
||||
if let Err(err) = discard_version_ini_transaction(game_root) {
|
||||
log::warn!(
|
||||
"Failed to discard version.ini transaction in {}: {err}",
|
||||
game_root.display_path().display()
|
||||
@@ -80,49 +77,34 @@ pub(super) async fn rollback_version_ini_transaction(game_root: &ConfinedGameRoo
|
||||
}
|
||||
|
||||
/// Restores the old sentinel after a crash or failure before ownership journaling.
|
||||
pub(super) async fn restore_unjournaled_version_ini_transaction(
|
||||
pub(super) fn restore_unjournaled_version_ini_transaction(
|
||||
game_root: &ConfinedGameRoot,
|
||||
) -> eyre::Result<()> {
|
||||
game_root
|
||||
.remove_root_file_if_exists(VERSION_TMP_FILE)
|
||||
.await?;
|
||||
if game_root.root_regular_file_exists(VERSION_INI).await? {
|
||||
game_root
|
||||
.remove_root_file_if_exists(VERSION_DISCARDED_FILE)
|
||||
.await?;
|
||||
game_root.remove_root_file_if_exists(VERSION_TMP_FILE)?;
|
||||
if game_root.root_regular_file_exists(VERSION_INI)? {
|
||||
game_root.remove_root_file_if_exists(VERSION_DISCARDED_FILE)?;
|
||||
return Ok(());
|
||||
}
|
||||
if game_root
|
||||
.root_regular_file_exists(VERSION_DISCARDED_FILE)
|
||||
.await?
|
||||
{
|
||||
game_root
|
||||
.rename_root_file(VERSION_DISCARDED_FILE, VERSION_INI)
|
||||
.await?;
|
||||
game_root.sync_root().await?;
|
||||
if game_root.root_regular_file_exists(VERSION_DISCARDED_FILE)? {
|
||||
game_root.rename_root_file(VERSION_DISCARDED_FILE, VERSION_INI)?;
|
||||
game_root.sync_root()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes all sentinel scratch after an aborted journaled download.
|
||||
pub(super) async fn discard_version_ini_transaction(
|
||||
game_root: &ConfinedGameRoot,
|
||||
) -> eyre::Result<()> {
|
||||
game_root
|
||||
.remove_root_file_if_exists(VERSION_TMP_FILE)
|
||||
.await?;
|
||||
game_root
|
||||
.remove_root_file_if_exists(VERSION_DISCARDED_FILE)
|
||||
.await?;
|
||||
game_root.sync_root().await?;
|
||||
pub(super) fn discard_version_ini_transaction(game_root: &ConfinedGameRoot) -> eyre::Result<()> {
|
||||
game_root.remove_root_file_if_exists(VERSION_TMP_FILE)?;
|
||||
game_root.remove_root_file_if_exists(VERSION_DISCARDED_FILE)?;
|
||||
game_root.sync_root()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sweeps scratch left after a committed sentinel was recovered.
|
||||
pub(super) async fn finish_recovered_version_ini_transaction(
|
||||
pub(super) fn finish_recovered_version_ini_transaction(
|
||||
game_root: &ConfinedGameRoot,
|
||||
) -> eyre::Result<()> {
|
||||
discard_version_ini_transaction(game_root).await
|
||||
discard_version_ini_transaction(game_root)
|
||||
}
|
||||
|
||||
pub(super) async fn commit_version_ini_buffer(
|
||||
@@ -132,6 +114,16 @@ pub(super) async fn commit_version_ini_buffer(
|
||||
commit_version_ini_buffer_with_sync(game_root, buffer, None).await
|
||||
}
|
||||
|
||||
fn write_and_sync_version_file(file: File, bytes: &[u8]) -> std::io::Result<()> {
|
||||
// The file is moved into this scope so write, durability, and close all
|
||||
// settle before the sentinel rename can begin.
|
||||
scoped_blocking(move || {
|
||||
let mut file = file;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()
|
||||
})
|
||||
}
|
||||
|
||||
async fn commit_version_ini_buffer_with_sync(
|
||||
game_root: &ConfinedGameRoot,
|
||||
buffer: &VersionIniBuffer,
|
||||
@@ -139,25 +131,17 @@ async fn commit_version_ini_buffer_with_sync(
|
||||
) -> eyre::Result<VersionIniCommit> {
|
||||
let bytes = buffer.snapshot().await;
|
||||
|
||||
let mut file =
|
||||
tokio::fs::File::from_std(game_root.create_new_root_file(VERSION_TMP_FILE).await?);
|
||||
file.write_all(&bytes).await?;
|
||||
file.sync_all().await?;
|
||||
drop(file);
|
||||
let file = game_root.create_new_root_file(VERSION_TMP_FILE)?;
|
||||
write_and_sync_version_file(file, &bytes)?;
|
||||
|
||||
game_root
|
||||
.rename_root_file(VERSION_TMP_FILE, VERSION_INI)
|
||||
.await?;
|
||||
game_root.rename_root_file(VERSION_TMP_FILE, VERSION_INI)?;
|
||||
if let Some(error) = injected_sync_error {
|
||||
return Ok(VersionIniCommit::NeedsRecovery(error));
|
||||
}
|
||||
if let Err(error) = game_root.sync_root().await {
|
||||
if let Err(error) = game_root.sync_root() {
|
||||
return Ok(VersionIniCommit::NeedsRecovery(error));
|
||||
}
|
||||
if let Err(error) = game_root
|
||||
.remove_root_file_if_exists(VERSION_DISCARDED_FILE)
|
||||
.await
|
||||
{
|
||||
if let Err(error) = game_root.remove_root_file_if_exists(VERSION_DISCARDED_FILE) {
|
||||
log::warn!(
|
||||
"Committed {} but failed to sweep the parked sentinel: {error}",
|
||||
game_root.display_path().join(VERSION_INI).display()
|
||||
@@ -171,9 +155,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::TempDir;
|
||||
|
||||
async fn open_game_root(temp: &TempDir) -> ConfinedGameRoot {
|
||||
fn open_game_root(temp: &TempDir) -> ConfinedGameRoot {
|
||||
ConfinedGameRoot::open_or_create(temp.path(), "game")
|
||||
.await
|
||||
.expect("confined game root should open")
|
||||
}
|
||||
|
||||
@@ -197,9 +180,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn commit_version_ini_writes_sentinel_last_and_sweeps_discarded() {
|
||||
let temp = TempDir::new("lanspread-download");
|
||||
let game_root = open_game_root(&temp).await;
|
||||
tokio::fs::write(temp.game_root().join(".version.ini.discarded"), b"old")
|
||||
.await
|
||||
let game_root = open_game_root(&temp);
|
||||
std::fs::write(temp.game_root().join(".version.ini.discarded"), b"old")
|
||||
.expect("discarded sentinel should be written");
|
||||
|
||||
let buffer =
|
||||
@@ -224,9 +206,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn landed_rename_with_failed_parent_sync_keeps_recovery_state() {
|
||||
let temp = TempDir::new("lanspread-version-durability");
|
||||
let game_root = open_game_root(&temp).await;
|
||||
tokio::fs::write(temp.game_root().join(VERSION_DISCARDED_FILE), b"20240101")
|
||||
.await
|
||||
let game_root = open_game_root(&temp);
|
||||
std::fs::write(temp.game_root().join(VERSION_DISCARDED_FILE), b"20240101")
|
||||
.expect("old sentinel should be parked");
|
||||
let buffer =
|
||||
VersionIniBuffer::new("game/version.ini", 8).expect("buffer should be created");
|
||||
@@ -245,28 +226,23 @@ mod tests {
|
||||
|
||||
assert!(matches!(outcome, VersionIniCommit::NeedsRecovery(_)));
|
||||
assert_eq!(
|
||||
tokio::fs::read(temp.game_root().join(VERSION_INI))
|
||||
.await
|
||||
std::fs::read(temp.game_root().join(VERSION_INI))
|
||||
.expect("new sentinel should be visible"),
|
||||
b"20250101"
|
||||
);
|
||||
assert!(temp.game_root().join(VERSION_DISCARDED_FILE).is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn begin_version_ini_transaction_parks_existing_sentinel() {
|
||||
#[test]
|
||||
fn begin_version_ini_transaction_parks_existing_sentinel() {
|
||||
let temp = TempDir::new("lanspread-download");
|
||||
let game_root = open_game_root(&temp).await;
|
||||
tokio::fs::write(temp.game_root().join("version.ini"), b"20240101")
|
||||
.await
|
||||
let game_root = open_game_root(&temp);
|
||||
std::fs::write(temp.game_root().join("version.ini"), b"20240101")
|
||||
.expect("version sentinel should be written");
|
||||
tokio::fs::write(temp.game_root().join(".version.ini.tmp"), b"partial")
|
||||
.await
|
||||
std::fs::write(temp.game_root().join(".version.ini.tmp"), b"partial")
|
||||
.expect("tmp sentinel should be written");
|
||||
|
||||
begin_version_ini_transaction(&game_root)
|
||||
.await
|
||||
.expect("transaction should begin");
|
||||
begin_version_ini_transaction(&game_root).expect("transaction should begin");
|
||||
|
||||
assert!(!temp.game_root().join("version.ini").exists());
|
||||
assert!(!temp.game_root().join(".version.ini.tmp").exists());
|
||||
@@ -278,39 +254,33 @@ mod tests {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn begin_can_inspect_and_park_read_only_sentinel() {
|
||||
#[test]
|
||||
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 game_root = open_game_root(&temp);
|
||||
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::write(&version_path, b"20240101").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");
|
||||
begin_version_ini_transaction(&game_root).expect("read-only sentinel should park");
|
||||
|
||||
assert!(!version_path.exists());
|
||||
assert!(temp.game_root().join(VERSION_DISCARDED_FILE).is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rollback_version_ini_transaction_sweeps_transients() {
|
||||
#[test]
|
||||
fn rollback_version_ini_transaction_sweeps_transients() {
|
||||
let temp = TempDir::new("lanspread-download");
|
||||
let game_root = open_game_root(&temp).await;
|
||||
tokio::fs::write(temp.game_root().join(".version.ini.tmp"), b"partial")
|
||||
.await
|
||||
let game_root = open_game_root(&temp);
|
||||
std::fs::write(temp.game_root().join(".version.ini.tmp"), b"partial")
|
||||
.expect("tmp sentinel should be written");
|
||||
tokio::fs::write(temp.game_root().join(".version.ini.discarded"), b"old")
|
||||
.await
|
||||
std::fs::write(temp.game_root().join(".version.ini.discarded"), b"old")
|
||||
.expect("discarded sentinel should be written");
|
||||
|
||||
rollback_version_ini_transaction(&game_root).await;
|
||||
rollback_version_ini_transaction(&game_root);
|
||||
|
||||
assert!(!temp.game_root().join(".version.ini.tmp").exists());
|
||||
assert!(!temp.game_root().join(".version.ini.discarded").exists());
|
||||
|
||||
Reference in new issue
Block a user