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
3947 lines
151 KiB
Rust
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, ¤t)?;
|
|
let untracked_targets = current
|
|
.difference(&previous)
|
|
.map(|path| ValidatedDownloadPath::from_ownership(path))
|
|
.collect::<eyre::Result<Vec<_>>>()?;
|
|
game_root.reject_existing_unowned_files(untracked_targets)?;
|
|
if needs_baseline {
|
|
let baseline =
|
|
DownloadOwnershipRecord::empty(manifest.game_id(), &games_folder_key);
|
|
require_durable_record(
|
|
publish_settled_record(
|
|
&record_path,
|
|
&tmp_path,
|
|
&recovery_required_path,
|
|
&baseline,
|
|
)?,
|
|
"download ownership baseline",
|
|
)?;
|
|
}
|
|
|
|
Ok(Self {
|
|
record_path,
|
|
tmp_path,
|
|
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, ®ular_file_options())?;
|
|
validate_regular_file_handle(&file, path)?;
|
|
Ok(file)
|
|
}
|
|
|
|
fn open_ambient_regular_file_nofollow(path: &Path) -> std::io::Result<std::fs::File> {
|
|
let file = cap_fs::open_ambient(path, ®ular_file_options(), ambient_authority())?;
|
|
validate_regular_file_handle(&file, path)?;
|
|
Ok(file)
|
|
}
|
|
|
|
fn directory_options() -> CapOpenOptions {
|
|
let mut options = CapOpenOptions::new();
|
|
options.read(true);
|
|
options
|
|
.maybe_dir(true)
|
|
.follow(FollowSymlinks::No)
|
|
.nonblock(true);
|
|
options
|
|
}
|
|
|
|
fn regular_file_options() -> CapOpenOptions {
|
|
let mut options = CapOpenOptions::new();
|
|
options.read(true);
|
|
options.follow(FollowSymlinks::No).nonblock(true);
|
|
options
|
|
}
|
|
|
|
fn validate_directory_handle(file: &std::fs::File, display: &Path) -> std::io::Result<()> {
|
|
let metadata = file.metadata()?;
|
|
if !metadata.is_dir() || is_windows_reparse(&metadata) {
|
|
return Err(std::io::Error::new(
|
|
ErrorKind::InvalidInput,
|
|
format!(
|
|
"download ownership state is not a non-reparse directory: {}",
|
|
display.display()
|
|
),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_regular_file_handle(file: &std::fs::File, display: &Path) -> std::io::Result<()> {
|
|
let metadata = file.metadata()?;
|
|
if !metadata.is_file() || is_windows_reparse(&metadata) {
|
|
return Err(std::io::Error::new(
|
|
ErrorKind::InvalidInput,
|
|
format!(
|
|
"download ownership state is not a regular non-reparse file: {}",
|
|
display.display()
|
|
),
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn is_windows_reparse(metadata: &std::fs::Metadata) -> bool {
|
|
use std::os::windows::fs::MetadataExt as _;
|
|
|
|
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
|
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
const fn is_windows_reparse(_metadata: &std::fs::Metadata) -> bool {
|
|
false
|
|
}
|
|
|
|
fn load_record(
|
|
path: &Path,
|
|
expected_game_id: &str,
|
|
expected_games_folder_key: &str,
|
|
) -> LoadedOwnership {
|
|
let 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"
|
|
);
|
|
}
|
|
}
|