Files
lanspread/crates/lanspread-peer/src/download/ownership.rs
T
ddidderr 60fd7ba0c2 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
2026-08-10 13:59:18 +02:00

3947 lines
151 KiB
Rust

//! Crash-consistent provenance for files created by peer downloads.
use std::{
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 super::{
confined_fs::ConfinedGameRoot,
manifest::{
MAX_DOWNLOAD_MANIFEST_ENTRIES,
MAX_DOWNLOAD_RELATIVE_PATH_BYTES,
ValidatedDownloadManifest,
ValidatedDownloadPath,
canonical_games_folder,
validate_game_id,
validate_owned_file_path,
},
version_ini::{
discard_version_ini_transaction,
finish_recovered_version_ini_transaction,
restore_unjournaled_version_ini_transaction,
},
};
use crate::{
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 = 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)]
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>>,
}
impl DownloadOwnershipRecord {
fn empty(game_id: &str, games_folder_key: &str) -> Self {
Self {
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,
}
}
fn validate(
self,
expected_game_id: &str,
expected_games_folder_key: &str,
) -> eyre::Result<Self> {
if self.schema_version != OWNERSHIP_SCHEMA_VERSION {
eyre::bail!(
"unsupported download ownership schema {}",
self.schema_version
);
}
if self.game_id != expected_game_id {
eyre::bail!(
"download ownership belongs to game {}, expected {expected_game_id}",
self.game_id
);
}
if self.games_folder_key != expected_games_folder_key {
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)
}
}
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(eyre::Report),
}
/// One download attempt whose previous and proposed ownership sets are durable.
#[derive(Debug)]
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(
state_dir: &Path,
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(
Some(game_root),
state_dir,
manifest.game_id(),
&games_folder_key,
)
.await?;
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,
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(
games_folder: &Path,
state_dir: &Path,
game_id: &str,
) -> eyre::Result<Option<Self>> {
validate_game_id(game_id)?;
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);
};
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> {
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) fn remove_stale(&self) -> eyre::Result<()> {
let stale = self
.previous
.difference(&self.current)
.cloned()
.collect::<BTreeSet<_>>();
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<()> {
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<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
}
}
/// Removes only files proven to belong to a completed peer download.
///
/// An empty pending ownership generation is the durable removal intent. That
/// lets normal startup recovery finish an interrupted removal without ever
/// recursively deleting the game root or unknown files.
pub(crate) async fn remove_downloaded_payload(
games_folder: &Path,
state_dir: &Path,
game_id: &str,
) -> eyre::Result<()> {
let Some(transaction) =
DownloadOwnershipTransaction::prepare_removal(games_folder, state_dir, game_id).await?
else {
return Ok(());
};
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)
{
return Err(error.wrap_err(format!(
"sentinel parking failed and rollback also failed: {restore_error}"
)));
}
return Err(error);
}
match transaction.journal_pending().await {
Ok(OwnershipJournalPublication::Durable) => {}
Ok(OwnershipJournalPublication::NeedsRecovery(error)) => {
return Err(eyre::eyre!(
"download removal ownership was renamed but its durability could not be established: {error}"
));
}
Err(error) => {
if let Err(restore_error) =
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}"
)));
}
return Err(error);
}
}
// From the durable empty pending generation onward, recovery must roll the
// removal forward. Never restore the sentinel on a later failure.
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.
pub(crate) async fn recover_incomplete_download(
game_root: &Path,
state_dir: &Path,
game_id: &str,
) -> eyre::Result<()> {
let Some(games_folder) = game_root.parent() else {
eyre::bail!(
"game root has no games-directory parent: {}",
game_root.display()
);
};
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 (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: Option<&ConfinedGameRoot>,
state_dir: &Path,
game_id: &str,
games_folder_key: &str,
) -> eyre::Result<()> {
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) {
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",
)
}
}
fn validate_file_set(paths: &[String]) -> eyre::Result<()> {
if paths.len() > MAX_DOWNLOAD_MANIFEST_ENTRIES {
eyre::bail!("download ownership has too many paths");
}
let mut previous: Option<&str> = None;
let mut aliases = BTreeSet::new();
for path in paths {
if path.len() > MAX_DOWNLOAD_RELATIVE_PATH_BYTES {
eyre::bail!("download ownership path is too long");
}
if previous.is_some_and(|previous| previous >= path.as_str()) {
eyre::bail!("download ownership paths are not strictly sorted");
}
let alias = validate_owned_file_path(path)?;
if !aliases.insert(alias) {
eyre::bail!("download ownership contains platform-alias paths");
}
previous = Some(path);
}
Ok(())
}
fn validate_generation_aliases(
previous: &BTreeSet<String>,
current: &BTreeSet<String>,
) -> eyre::Result<()> {
let mut previous_aliases = std::collections::BTreeMap::new();
for path in previous {
previous_aliases.insert(validate_owned_file_path(path)?, path);
}
for path in current {
let alias = validate_owned_file_path(path)?;
if let Some(previous_path) = previous_aliases.get(&alias)
&& *previous_path != path
{
eyre::bail!(
"download path changed only by a portable filesystem alias: {previous_path} -> {path}"
);
}
}
Ok(())
}
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 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!(
"Ignoring unreadable download ownership {}: {error}",
path.display()
);
return LoadedOwnership::Invalid;
}
};
let bytes = match read_bounded_file(file, MAX_OWNERSHIP_RECORD_BYTES) {
Ok(bytes) => bytes,
Err(error) => {
log::warn!(
"Ignoring unreadable download ownership {}: {error}",
path.display()
);
return LoadedOwnership::Invalid;
}
};
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}",
path.display()
);
LoadedOwnership::Invalid
}
}
}
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)
}
fn write_record_with_parent_sync(
path: &Path,
tmp_path: &Path,
record: &DownloadOwnershipRecord,
sync_parent: impl FnOnce(&Path) -> std::io::Result<()>,
) -> eyre::Result<OwnershipJournalPublication> {
let parent = path
.parent()
.ok_or_else(|| eyre::eyre!("download ownership path has no parent"))?;
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 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);
std::fs::rename(tmp_path, path)?;
if let Err(error) = sync_parent(path) {
return Ok(OwnershipJournalPublication::NeedsRecovery(error.into()));
}
Ok(OwnershipJournalPublication::Durable)
}
fn require_durable_record(
publication: OwnershipJournalPublication,
label: &str,
) -> eyre::Result<()> {
match publication {
OwnershipJournalPublication::Durable => Ok(()),
OwnershipJournalPublication::NeedsRecovery(error) => Err(error).wrap_err(format!(
"{label} was renamed but its durability could not be established"
)),
}
}
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)
}
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 std::fs::symlink_metadata(candidate) {
Ok(metadata) => {
if !metadata.is_dir() || is_windows_reparse(&metadata) {
eyre::bail!(
"download ownership parent is not a directory: {}",
candidate.display()
);
}
break;
}
Err(error) if error.kind() == ErrorKind::NotFound => {
missing.push(candidate.to_path_buf());
cursor = candidate.parent();
}
Err(error) => return Err(error.into()),
}
}
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(())
}
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()),
}
}
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()
);
}
}
#[cfg(test)]
pub(crate) async fn seed_download_ownership_for_test(
state_dir: &Path,
games_folder: &Path,
game_id: &str,
committed_files: &[&str],
) {
seed_download_ownership_generation_for_test(
state_dir,
games_folder,
game_id,
committed_files,
None,
)
.await;
}
#[cfg(test)]
const fn test_content_id() -> ContentId {
ContentId::from_bytes([0x42; 32])
}
#[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(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)]
fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::File::open(parent)?.sync_all()?;
}
Ok(())
}
#[cfg(not(unix))]
const fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use lanspread_db::content_manifest::{
Blake3Digest,
CatalogContentManifest,
CatalogContentManifestBody,
CatalogFileEntry,
};
use super::*;
use crate::{
game_paths::{LOCAL_DIR, VERSION_DISCARDED_FILE, VERSION_INI},
test_support::TempDir,
};
fn manifest(games_folder: &Path, files: &[&str]) -> ValidatedDownloadManifest {
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")
}));
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]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent should be created");
}
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()),
};
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 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())
.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 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");
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()));
let publication = transaction
.finalize()
.await
.expect("record should finalize");
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]
fn record_validation_rejects_untrusted_paths_and_bindings() {
let valid = DownloadOwnershipRecord {
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());
for paths in [
vec!["../outside".to_owned()],
vec!["local/save.dat".to_owned()],
vec!["A.eti".to_owned(), "a.eti".to_owned()],
vec!["z.eti".to_owned(), "a.eti".to_owned()],
vec![VERSION_INI.to_owned()],
vec!["Version.ini".to_owned()],
vec!["VERSION.INI".to_owned()],
] {
let mut invalid = valid.clone();
invalid.committed_files = paths;
assert!(invalid.validate("game", "root").is_err());
}
let mut wrong_schema = valid.clone();
wrong_schema.schema_version += 1;
assert!(wrong_schema.validate("game", "root").is_err());
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());
}
#[tokio::test]
async fn untracked_exact_manifest_target_is_rejected_without_mutation() {
let games = TempDir::new("lanspread-ownership-untracked-games");
let state = TempDir::new("lanspread-ownership-untracked-state");
let root = games.game_root();
write_file(&root.join(VERSION_INI), b"20240101");
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);
let error = DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root)
.await
.expect_err("an untracked exact target must be preserved");
assert!(error.to_string().contains("untracked file"));
assert_eq!(
std::fs::read(root.join(VERSION_INI)).expect("sentinel should remain readable"),
b"20240101"
);
assert_eq!(
std::fs::read(root.join("archive.eti")).expect("target should remain readable"),
b"user-bytes"
);
assert_eq!(
std::fs::read(root.join("notes.txt")).expect("user note should remain readable"),
b"user-note"
);
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
assert!(!ownership_record_path(state.path(), games.path()).exists());
}
#[tokio::test]
async fn replacement_and_abort_touch_only_explicitly_owned_paths() {
let games = TempDir::new("lanspread-ownership-replace-games");
let state = TempDir::new("lanspread-ownership-replace-state");
let root = games.game_root();
for (path, bytes) in [
("keep.eti", b"keep".as_slice()),
("stale.eti", b"old".as_slice()),
("nested/stale.bin", b"old".as_slice()),
("notes.txt", b"user".as_slice()),
("local/save.dat", b"save".as_slice()),
] {
write_file(&root.join(path), bytes);
}
write_file(&root.join(VERSION_INI), b"20240101");
seed_record(
state.path(),
games.path(),
&["keep.eti", "nested/stale.bin", "stale.eti"],
None,
)
.await;
let manifest = manifest(games.path(), &["keep.eti", "new.eti"]);
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)
.expect("sentinel should park");
transaction
.journal_pending()
.await
.expect("pending should journal");
transaction
.remove_stale()
.expect("stale cleanup should succeed");
assert!(root.join("keep.eti").is_file());
assert!(!root.join("stale.eti").exists());
assert!(!root.join("nested/stale.bin").exists());
assert!(root.join("nested").is_dir());
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"
);
transaction.abort().await.expect("abort should succeed");
assert!(!root.join("keep.eti").exists());
assert!(!root.join("new.eti").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 removal_deletes_only_owned_payload_and_keeps_an_empty_journal() {
let games = TempDir::new("lanspread-ownership-remove-games");
let state = TempDir::new("lanspread-ownership-remove-state");
let root = games.game_root();
for (path, bytes) in [
(VERSION_INI, b"20240101".as_slice()),
("archive.eti", b"owned".as_slice()),
("nested/owned.bin", b"owned".as_slice()),
("notes.txt", b"user".as_slice()),
("nested/user.txt", b"user".as_slice()),
] {
write_file(&root.join(path), bytes);
}
seed_record(
state.path(),
games.path(),
&["archive.eti", "nested/owned.bin"],
None,
)
.await;
remove_downloaded_payload(games.path(), state.path(), "game")
.await
.expect("owned download should be removable");
assert!(root.is_dir());
assert!(!root.join(VERSION_INI).exists());
assert!(!root.join("archive.eti").exists());
assert!(!root.join("nested/owned.bin").exists());
assert_eq!(
std::fs::read(root.join("notes.txt")).expect("user file should remain"),
b"user"
);
assert_eq!(
std::fs::read(root.join("nested/user.txt")).expect("nested user file should remain"),
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")
.await
.expect("completed removal should be idempotent");
assert_eq!(
std::fs::read(root.join("notes.txt")).expect("retry must preserve user file"),
b"user"
);
}
#[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())] {
let games = TempDir::new("lanspread-ownership-remove-untracked-games");
let state = TempDir::new("lanspread-ownership-remove-untracked-state");
let root = games.game_root();
write_file(&root.join(VERSION_INI), b"20240101");
write_file(&root.join("archive.eti"), b"ambiguous");
write_file(&root.join("notes.txt"), b"user");
if let Some(bytes) = invalid_record {
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")
|| error.to_string().contains("invalid record")
);
assert_eq!(
std::fs::read(root.join(VERSION_INI)).expect("sentinel should remain"),
b"20240101"
);
assert_eq!(
std::fs::read(root.join("archive.eti")).expect("payload should remain"),
b"ambiguous"
);
assert_eq!(
std::fs::read(root.join("notes.txt")).expect("user file should remain"),
b"user"
);
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
}
}
#[tokio::test]
async fn removal_refuses_install_state_before_parking_the_sentinel() {
for protected in [LOCAL_DIR, INSTALLING_DIR, BACKUP_DIR] {
let games = TempDir::new("lanspread-ownership-remove-installed-games");
let state = TempDir::new("lanspread-ownership-remove-installed-state");
let root = games.game_root();
write_file(&root.join(VERSION_INI), b"20240101");
write_file(&root.join("archive.eti"), b"owned");
write_file(&root.join(protected).join("canary"), b"protected");
seed_record(state.path(), games.path(), &["archive.eti"], None).await;
assert!(
remove_downloaded_payload(games.path(), state.path(), "game")
.await
.is_err()
);
assert!(root.join(VERSION_INI).is_file());
assert!(root.join("archive.eti").is_file());
assert_eq!(
std::fs::read(root.join(protected).join("canary"))
.expect("protected state should remain"),
b"protected"
);
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
}
}
#[tokio::test]
async fn recovery_rolls_a_durable_removal_intent_forward() {
let games = TempDir::new("lanspread-ownership-remove-recovery-games");
let state = TempDir::new("lanspread-ownership-remove-recovery-state");
let root = games.game_root();
write_file(&root.join(VERSION_DISCARDED_FILE), b"20240101");
write_file(&root.join("archive.eti"), b"owned");
write_file(&root.join("notes.txt"), b"user");
seed_record(state.path(), games.path(), &["archive.eti"], Some(&[])).await;
recover_incomplete_download(&root, state.path(), "game")
.await
.expect("removal should recover");
assert!(!root.join(VERSION_INI).exists());
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
assert!(!root.join("archive.eti").exists());
assert_eq!(
std::fs::read(root.join("notes.txt")).expect("user file should remain"),
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_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("baseline ownership should be durable");
super::super::version_ini::begin_version_ini_transaction(&confined_root)
.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());
}
#[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(),
state.path(),
"game",
pending_content_id,
)
.await
);
assert!(
!download_ownership_matches_content(
games.path(),
state.path(),
"game",
committed_content_id,
)
.await
);
}
#[tokio::test]
async fn missing_legacy_record_never_restores_ambiguous_discarded_sentinel() {
let games = TempDir::new("lanspread-ownership-legacy-games");
let state = TempDir::new("lanspread-ownership-legacy-state");
let root = games.game_root();
write_file(&root.join("archive.eti"), b"possibly-partial");
write_file(&root.join(VERSION_DISCARDED_FILE), b"20240101");
recover_incomplete_download(&root, state.path(), "game")
.await
.expect("legacy scratch should fail closed");
assert!(!root.join(VERSION_INI).exists());
assert!(!root.join(VERSION_DISCARDED_FILE).exists());
assert_eq!(
std::fs::read(root.join("archive.eti")).expect("unknown payload should remain"),
b"possibly-partial"
);
}
#[tokio::test]
async fn published_record_reports_post_rename_sync_uncertainty() {
let games = TempDir::new("lanspread-ownership-publish-games");
let state = TempDir::new("lanspread-ownership-publish-state");
let record = DownloadOwnershipRecord {
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 = 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"))
})
.expect("post-publication sync failure must remain phase-aware");
assert!(matches!(
publication,
OwnershipJournalPublication::NeedsRecovery(_)
));
assert_eq!(read_valid_record(state.path(), games.path()).await, record);
}
#[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");
let state = TempDir::new("lanspread-ownership-alias-state");
let root = games.game_root();
write_file(&root.join(VERSION_INI), b"20240101");
write_file(&root.join("Archive.eti"), b"old");
seed_record(state.path(), games.path(), &["Archive.eti"], None).await;
let manifest = manifest(games.path(), &["archive.eti"]);
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");
assert!(error.to_string().contains("portable filesystem alias"));
assert!(root.join(VERSION_INI).is_file());
assert_eq!(
std::fs::read(root.join("Archive.eti")).expect("old payload should remain"),
b"old"
);
assert!(!root.join("archive.eti").exists());
}
#[tokio::test]
async fn recovery_never_trusts_cross_generation_aliases() {
let games = TempDir::new("lanspread-ownership-alias-recovery-games");
let state = TempDir::new("lanspread-ownership-alias-recovery-state");
let root = games.game_root();
write_file(&root.join(VERSION_INI), b"20240101");
write_file(&root.join("archive.eti"), b"must-survive");
seed_record(
state.path(),
games.path(),
&["Archive.eti"],
Some(&["archive.eti"]),
)
.await;
let error = recover_incomplete_download(&root, state.path(), "game")
.await
.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"),
b"must-survive"
);
assert!(root.join(VERSION_INI).is_file());
}
#[tokio::test]
async fn committed_path_ownership_survives_external_root_recreation() {
let games = TempDir::new("lanspread-ownership-recreated-root-games");
let state = TempDir::new("lanspread-ownership-recreated-root-state");
let root = games.game_root();
write_file(&root.join(VERSION_INI), b"20240101");
write_file(&root.join("archive.eti"), b"old-owned");
seed_record(state.path(), games.path(), &["archive.eti"], None).await;
std::fs::remove_dir_all(&root).expect("old game root should be removed externally");
write_file(&root.join(VERSION_INI), b"20240101");
write_file(&root.join("archive.eti"), b"replacement");
let manifest = manifest(games.path(), &["archive.eti"]);
let confined_root = confined_root(&manifest);
DownloadOwnershipTransaction::prepare(state.path(), &manifest, &confined_root)
.await
.expect("path ownership deliberately survives local root replacement");
assert_eq!(
std::fs::read(root.join("archive.eti")).expect("prepare must not mutate the target"),
b"replacement"
);
assert!(root.join(VERSION_INI).is_file());
}
#[tokio::test]
async fn corrupt_or_wrong_root_state_never_authorizes_payload_deletion() {
let games_a = TempDir::new("lanspread-ownership-binding-a");
let games_b = TempDir::new("lanspread-ownership-binding-b");
let state = TempDir::new("lanspread-ownership-binding-state");
seed_record(
state.path(),
games_a.path(),
&["archive.eti"],
Some(&["archive.eti"]),
)
.await;
write_file(&games_b.game_root().join("archive.eti"), b"other-library");
write_file(
&games_b.game_root().join(VERSION_DISCARDED_FILE),
b"20240101",
);
recover_incomplete_download(&games_b.game_root(), state.path(), "game")
.await
.expect("wrong binding should fail closed");
assert_eq!(
std::fs::read(games_b.game_root().join("archive.eti"))
.expect("foreign payload should remain readable"),
b"other-library"
);
assert!(!games_b.game_root().join(VERSION_INI).exists());
write_file(
&ownership_record_path(state.path(), games_b.path()),
b"corrupt",
);
write_file(&games_b.game_root().join("partial.eti"), b"unknown");
let error = recover_incomplete_download(&games_b.game_root(), state.path(), "game")
.await
.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"),
b"unknown"
);
}
#[cfg(unix)]
#[tokio::test]
async fn cleanup_never_follows_owned_symlink_paths() {
use std::os::unix::fs::symlink;
let games = TempDir::new("lanspread-ownership-link-games");
let state = TempDir::new("lanspread-ownership-link-state");
let outside = TempDir::new("lanspread-ownership-link-outside");
let root = games.game_root();
write_file(&outside.path().join("canary"), b"outside");
std::fs::create_dir_all(&root).expect("root should be created");
symlink(outside.path(), root.join("linked")).expect("symlink should be created");
seed_record(state.path(), games.path(), &[], Some(&["linked/canary"])).await;
recover_incomplete_download(&root, state.path(), "game")
.await
.expect("recovery should preserve the link");
assert!(root.join("linked").is_symlink());
assert_eq!(
std::fs::read(outside.path().join("canary"))
.expect("outside canary should remain readable"),
b"outside"
);
}
}