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:
ddidderr committed 2026-08-10 13:59:18 +02:00
1 parent 36c4785775
commit 60fd7ba0c2
128 files changed
+51759 -10784

No files matched your search

+208 -94
View File
@@ -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());
+240 -740
View File
@@ -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"
);
}
}
+17 -4
View File
@@ -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};
+527 -175
View File
@@ -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");
}
}
+2996 -442
View File
@@ -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, &current)?;
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, &current)?;
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, &regular_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, &regular_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"),
+364 -105
View File
@@ -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"));
}
}
+87 -51
View File
@@ -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);
}
}
+411 -172
View File
@@ -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"));
}
}
+31 -27
View File
@@ -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);
}
}
}
+896 -170
View File
@@ -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());