fix(peer): preserve untracked download files

Reject exact manifest destinations that are not covered by the last committed ownership set before creating a baseline or parking version.ini. Align Windows device-name validation with the confined filesystem backend and keep cleanup capability-relative.

Replace recursive downloaded-game removal with an empty ownership generation. The operation now removes only proven-owned files and the sentinel, preserves unknown files and directories, and remains recoverable and idempotent across crashes.

Test Plan:
- just clippy
- just test
- just fmt (Rust, TOML, and Prettier completed; rumdl still reports 39 pre-existing issues)
This commit is contained in:
ddidderr committed 2026-08-09 19:46:10 +02:00
1 parent 691176e1d5
commit 08b1cb5c1d
10 files changed
+630 -301

No files matched your search

+129 -16
View File
@@ -142,7 +142,8 @@ impl ConfinedGameRoot {
}
for parent in parent_directories {
let parent = ValidatedDownloadPath::from_ownership(&parent)?;
root.open_directory_blocking(&parent, false)?.sync_all()?;
let directory = root.open_directory_blocking(&parent, false)?;
sync_directory_handle(&directory)?;
}
sync_directory_handle(&root.inner.game_root)?;
Ok(())
@@ -164,6 +165,20 @@ impl ConfinedGameRoot {
.await?
}
pub(super) async fn reject_existing_unowned_files(
&self,
paths: Vec<ValidatedDownloadPath>,
) -> eyre::Result<()> {
let root = self.clone();
tokio::task::spawn_blocking(move || {
for path in &paths {
root.reject_existing_unowned_file_blocking(path)?;
}
Ok(())
})
.await?
}
pub(super) async fn root_regular_file_exists(&self, name: &'static str) -> eyre::Result<bool> {
let root = self.clone();
tokio::task::spawn_blocking(
@@ -182,6 +197,18 @@ impl ConfinedGameRoot {
.await?
}
pub(super) async fn root_entry_exists(&self, name: &'static str) -> eyre::Result<bool> {
let root = self.clone();
tokio::task::spawn_blocking(move || {
match fs::stat(&root.inner.game_root, Path::new(name), FollowSymlinks::No) {
Ok(_) => Ok(true),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
})
.await?
}
pub(super) async fn create_new_root_file(&self, name: &'static str) -> eyre::Result<File> {
let root = self.clone();
tokio::task::spawn_blocking(move || root.create_new_root_file_blocking(name)).await?
@@ -190,6 +217,20 @@ impl ConfinedGameRoot {
pub(super) async fn remove_root_file_if_exists(&self, name: &'static str) -> eyre::Result<()> {
let root = self.clone();
tokio::task::spawn_blocking(move || {
#[cfg(windows)]
match root.inspect_root_regular_file_blocking(name) {
Ok(file) => {
make_windows_file_removable(&root.inner.game_root, Path::new(name), &file)?
}
Err(error)
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|error| error.kind() == ErrorKind::NotFound) =>
{
return Ok(());
}
Err(error) => return Err(error),
}
match fs::remove_file(&root.inner.game_root, Path::new(name)) {
Ok(()) => {
sync_directory_handle(&root.inner.game_root)?;
@@ -305,24 +346,56 @@ impl ConfinedGameRoot {
return Ok(());
}
// Opening and inspecting the same object before unlink catches Windows
// reparse points that are not reported as ordinary symlinks.
let file = match inspect_regular_file_at(&parent, leaf) {
Ok(file) => file,
Err(error) if is_preservable_shape_error(&error) => {
log::warn!(
"Preserving owned path whose final object changed at {}/{}",
self.inner.display_path.display(),
path.canonical()
);
// Windows has non-symlink reparse points that require same-handle
// inspection. On Unix, opening merely to unlink would incorrectly make
// cleanup depend on the owned file's read permission.
#[cfg(windows)]
{
let file = match inspect_regular_file_at(&parent, leaf) {
Ok(file) => file,
Err(error) if is_preservable_shape_error(&error) => {
log::warn!(
"Preserving owned path whose final object changed at {}/{}",
self.inner.display_path.display(),
path.canonical()
);
return Ok(());
}
Err(error) => return Err(error),
};
make_windows_file_removable(&parent, Path::new(leaf), &file)?;
drop(file);
}
fs::remove_file(&parent, Path::new(leaf))?;
sync_directory_handle(&parent)?;
Ok(())
}
fn reject_existing_unowned_file_blocking(
&self,
path: &ValidatedDownloadPath,
) -> eyre::Result<()> {
let (parent, leaf) = match self.open_parent_blocking(path, false) {
Ok(value) => value,
Err(error)
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|error| error.kind() == ErrorKind::NotFound) =>
{
return Ok(());
}
Err(error) => return Err(error),
};
drop(file);
fs::remove_file(&parent, Path::new(leaf))?;
sync_directory_handle(&parent)?;
Ok(())
match fs::stat(&parent, Path::new(leaf), FollowSymlinks::No) {
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
Ok(_) => eyre::bail!(
"refusing to replace untracked file at {}/{}",
self.inner.display_path.display(),
path.canonical()
),
}
}
fn open_parent_for_cleanup_blocking<'a>(
@@ -410,6 +483,7 @@ fn open_regular_file_at(parent: &File, leaf: &str, create: bool) -> eyre::Result
Ok(file)
}
#[cfg(windows)]
fn inspect_regular_file_at(parent: &File, leaf: &str) -> eyre::Result<File> {
let options = inspection_file_options();
let file = fs::open(parent, Path::new(leaf), &options)?;
@@ -477,6 +551,20 @@ fn reject_windows_reparse(metadata: &std::fs::Metadata, display: &str) -> std::i
Ok(())
}
#[cfg(windows)]
fn make_windows_file_removable(parent: &File, path: &Path, file: &File) -> std::io::Result<()> {
let mut permissions = file.metadata()?.permissions();
if permissions.readonly() {
permissions.set_readonly(false);
fs::set_symlink_permissions(
parent,
path,
cap_primitives::fs::Permissions::from_std(permissions),
)?;
}
Ok(())
}
#[cfg(not(windows))]
#[allow(clippy::unnecessary_wraps)]
const fn reject_windows_reparse(
@@ -486,11 +574,15 @@ const fn reject_windows_reparse(
Ok(())
}
#[cfg(windows)]
fn is_preservable_shape_error(error: &eyre::Report) -> bool {
error.downcast_ref::<std::io::Error>().is_some_and(|error| {
matches!(
error.kind(),
ErrorKind::NotFound | ErrorKind::NotADirectory | ErrorKind::InvalidInput
ErrorKind::NotFound
| ErrorKind::NotADirectory
| ErrorKind::InvalidInput
| ErrorKind::FilesystemLoop
)
})
}
@@ -668,4 +760,25 @@ mod tests {
assert!(!payload.exists());
}
#[cfg(unix)]
#[tokio::test]
async fn cleanup_does_not_require_read_access_to_owned_files() {
use std::os::unix::fs::PermissionsExt as _;
let games = TempDir::new("lanspread-confined-mode-zero-cleanup");
let root = ConfinedGameRoot::open_or_create(games.path(), "game")
.await
.expect("game root should open");
let payload = games.game_root().join("payload.bin");
std::fs::write(&payload, b"owned").expect("payload should be written");
std::fs::set_permissions(&payload, std::fs::Permissions::from_mode(0o000))
.expect("payload permissions should be removed");
root.remove_owned_regular_files(vec![path("payload.bin")])
.await
.expect("mode-zero owned file should be removable by its parent owner");
assert!(!payload.exists());
}
}
+13 -4
View File
@@ -382,7 +382,7 @@ enum EntryShape {
Directory,
}
fn validate_game_id(game_id: &str) -> eyre::Result<()> {
pub(super) fn validate_game_id(game_id: &str) -> eyre::Result<()> {
if game_id.contains('/') || game_id.contains('\\') {
eyre::bail!("catalog game ID must be one path component: {game_id}");
}
@@ -406,7 +406,7 @@ fn validate_protocol_game_id(
Ok(())
}
fn canonical_games_folder(games_folder: &Path) -> eyre::Result<PathBuf> {
pub(super) fn canonical_games_folder(games_folder: &Path) -> eyre::Result<PathBuf> {
if !games_folder.is_absolute() {
eyre::bail!(
"configured games directory must be absolute: {}",
@@ -485,7 +485,10 @@ fn validate_component(component: &str) -> eyre::Result<()> {
}) {
eyre::bail!("download path component is not portable: {component}");
}
let device_stem = component.split('.').next().unwrap_or_default();
// Match the Windows path backend's device-name normalization. Windows
// ignores trailing Unicode whitespace in the stem before the first dot,
// and reserves the zero-numbered serial/parallel aliases as well.
let device_stem = component.split('.').next().unwrap_or_default().trim_end();
if is_windows_device_name(device_stem) {
eyre::bail!("download path uses a Windows device name: {component}");
}
@@ -507,7 +510,7 @@ fn is_windows_device_name(stem: &str) -> bool {
.strip_prefix("COM")
.or_else(|| upper.strip_prefix("LPT"))
.is_some_and(|number| {
(number.len() == 1 && matches!(number.as_bytes()[0], b'1'..=b'9'))
(number.len() == 1 && number.as_bytes()[0].is_ascii_digit())
|| matches!(number, "¹" | "²" | "³")
})
}
@@ -812,8 +815,12 @@ mod tests {
let temp = TempDir::new("lanspread-manifest-portability");
for path in [
"game/.ſync/state",
"game/COM0",
"game/LPT0.txt",
"game/COM¹.txt",
"game/LPT³.log",
"game/CON .txt",
"game/CON\u{00a0}",
"game/LOCAL~1/save.dat",
] {
let descriptions = vec![file(path, 1), file("game/version.ini", 8)];
@@ -985,6 +992,8 @@ mod tests {
vec![file("game/version.ini", 8), file("other/local/save.dat", 1)],
vec![file("game/version.ini", 8), file("game/local/save.dat", 1)],
vec![file("game/version.ini", 8), file("game/.sync/state", 1)],
vec![file("game/version.ini", 8), file("game/COM0", 1)],
vec![file("game/version.ini", 8), file("game/CON\u{00a0}", 1)],
vec![file("game/version.ini", 8), file("game/../escape", 1)],
vec![
file("game/version.ini", 8),
+3 -1
View File
@@ -13,4 +13,6 @@ mod version_ini;
pub(crate) use manifest::{ValidatedDownloadManifest, validate_protocol_v7_descriptions};
pub(crate) use orchestrator::download_game_files;
pub(crate) use ownership::{clear_download_ownership, recover_incomplete_download};
#[cfg(test)]
pub(crate) use ownership::seed_download_ownership_for_test;
pub(crate) use ownership::{recover_incomplete_download, remove_downloaded_payload};
+372 -28
View File
@@ -17,6 +17,8 @@ use super::{
MAX_DOWNLOAD_RELATIVE_PATH_BYTES,
ValidatedDownloadManifest,
ValidatedDownloadPath,
canonical_games_folder,
validate_game_id,
validate_owned_file_path,
},
version_ini::{
@@ -25,7 +27,10 @@ use super::{
restore_unjournaled_version_ini_transaction,
},
};
use crate::state_paths::{download_ownership_path, download_ownership_tmp_path};
use crate::{
game_paths::{BACKUP_DIR, INSTALLING_DIR, LOCAL_DIR, VERSION_INI},
state_paths::{download_ownership_path, download_ownership_tmp_path},
};
const OWNERSHIP_SCHEMA_VERSION: u32 = 1;
const MAX_OWNERSHIP_RECORD_BYTES: u64 = 128 * 1024 * 1024;
@@ -122,21 +127,29 @@ impl DownloadOwnershipTransaction {
let record_path = download_ownership_path(state_dir, manifest.game_id());
let tmp_path = download_ownership_tmp_path(state_dir, manifest.game_id());
let previous = match load_record(&record_path, manifest.game_id(), &games_folder_key).await
{
LoadedOwnership::Valid(record) => record.committed_files.into_iter().collect(),
LoadedOwnership::Missing | LoadedOwnership::Invalid => {
let baseline =
DownloadOwnershipRecord::empty(manifest.game_id(), &games_folder_key);
require_durable_record(
write_record(&record_path, &tmp_path, &baseline).await?,
"download ownership baseline",
)?;
BTreeSet::new()
}
};
let (previous, needs_baseline) =
match load_record(&record_path, manifest.game_id(), &games_folder_key).await {
LoadedOwnership::Valid(record) => {
(record.committed_files.into_iter().collect(), false)
}
LoadedOwnership::Missing | LoadedOwnership::Invalid => (BTreeSet::new(), true),
};
let current = manifest.owned_file_paths().into_iter().collect();
validate_generation_aliases(&previous, &current)?;
let untracked_targets = current
.difference(&previous)
.map(|path| ValidatedDownloadPath::from_ownership(path))
.collect::<eyre::Result<Vec<_>>>()?;
game_root
.reject_existing_unowned_files(untracked_targets)
.await?;
if needs_baseline {
let baseline = DownloadOwnershipRecord::empty(manifest.game_id(), &games_folder_key);
require_durable_record(
write_record(&record_path, &tmp_path, &baseline).await?,
"download ownership baseline",
)?;
}
Ok(Self {
record_path,
@@ -149,6 +162,73 @@ impl DownloadOwnershipTransaction {
})
}
async fn prepare_removal(
games_folder: &Path,
state_dir: &Path,
game_id: &str,
) -> eyre::Result<Option<Self>> {
validate_game_id(game_id)?;
let games_folder_path = games_folder.to_path_buf();
let games_folder =
tokio::task::spawn_blocking(move || canonical_games_folder(&games_folder_path))
.await??;
let games_folder_key = games_folder_key(&games_folder);
let record_path = download_ownership_path(state_dir, game_id);
let tmp_path = download_ownership_tmp_path(state_dir, game_id);
let Some(game_root) = ConfinedGameRoot::open_existing(&games_folder, game_id).await? else {
let empty = DownloadOwnershipRecord::empty(game_id, &games_folder_key);
require_durable_record(
write_record(&record_path, &tmp_path, &empty).await?,
"absent downloaded-game ownership",
)?;
return Ok(None);
};
recover_incomplete_download_with_root(&game_root, state_dir, game_id, &games_folder_key)
.await?;
let record = match load_record(&record_path, game_id, &games_folder_key).await {
LoadedOwnership::Valid(record) => record,
LoadedOwnership::Missing => {
eyre::bail!(
"cannot safely remove downloaded files for {game_id}: the ownership record is missing; move or delete the legacy game folder manually"
);
}
LoadedOwnership::Invalid => {
eyre::bail!(
"cannot safely remove downloaded files for {game_id}: the ownership record is invalid; move or delete the game folder manually"
);
}
};
if record.pending_files.is_some() {
eyre::bail!("download ownership recovery did not settle for {game_id}");
}
if !game_root.root_regular_file_exists(VERSION_INI).await? {
if record.committed_files.is_empty() {
return Ok(None);
}
eyre::bail!("download sentinel is missing for {game_id}");
}
for (name, label) in [
(LOCAL_DIR, "local install"),
(INSTALLING_DIR, "install staging"),
(BACKUP_DIR, "install backup"),
] {
if game_root.root_entry_exists(name).await? {
eyre::bail!("refusing to remove downloaded files for {game_id} with {label}");
}
}
Ok(Some(Self {
record_path,
tmp_path,
game_id: game_id.to_owned(),
games_folder_key,
game_root,
previous: record.committed_files.into_iter().collect(),
current: BTreeSet::new(),
}))
}
/// Publishes the proposed set after the old sentinel has been parked.
pub(super) async fn journal_pending(&self) -> eyre::Result<OwnershipJournalPublication> {
let record = DownloadOwnershipRecord {
@@ -203,6 +283,60 @@ impl DownloadOwnershipTransaction {
}
}
/// 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).await
{
if let Err(restore_error) =
restore_unjournaled_version_ini_transaction(&transaction.game_root).await
{
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).await
{
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().await?;
discard_version_ini_transaction(&transaction.game_root).await?;
transaction.finalize().await
}
/// Recovers the ownership/version transaction for one inactive game root.
pub(crate) async fn recover_incomplete_download(
game_root: &Path,
@@ -298,19 +432,6 @@ async fn recover_incomplete_download_with_root(
Ok(())
}
/// Clears provenance after an explicit downloaded-game removal succeeds.
pub(crate) async fn clear_download_ownership(state_dir: &Path, game_id: &str) -> eyre::Result<()> {
let path = download_ownership_path(state_dir, game_id);
remove_file_if_exists(&download_ownership_tmp_path(state_dir, game_id)).await?;
remove_file_if_exists(&path).await?;
if let Err(error) = sync_parent_dir(&path)
&& error.kind() != ErrorKind::NotFound
{
return Err(error.into());
}
Ok(())
}
fn validate_file_set(paths: &[String]) -> eyre::Result<()> {
if paths.len() > MAX_DOWNLOAD_MANIFEST_ENTRIES {
eyre::bail!("download ownership has too many paths");
@@ -507,6 +628,34 @@ async fn sweep_tmp_file(path: &Path) {
}
}
#[cfg(test)]
pub(crate) async fn seed_download_ownership_for_test(
state_dir: &Path,
games_folder: &Path,
game_id: &str,
committed_files: &[&str],
) {
let games_folder = canonical_games_folder(games_folder).expect("games folder should resolve");
let record = DownloadOwnershipRecord {
schema_version: OWNERSHIP_SCHEMA_VERSION,
game_id: game_id.to_owned(),
games_folder_key: games_folder_key(&games_folder),
committed_files: committed_files.iter().map(ToString::to_string).collect(),
pending_files: None,
};
require_durable_record(
write_record(
&download_ownership_path(state_dir, game_id),
&download_ownership_tmp_path(state_dir, game_id),
&record,
)
.await
.expect("test ownership should publish"),
"test ownership",
)
.expect("test ownership should be durable");
}
#[cfg(unix)]
fn games_folder_key(path: &Path) -> String {
use std::os::unix::ffi::OsStrExt;
@@ -707,6 +856,38 @@ mod tests {
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).await;
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!(!download_ownership_path(state.path(), "game").exists());
}
#[tokio::test]
async fn replacement_and_abort_touch_only_explicitly_owned_paths() {
let games = TempDir::new("lanspread-ownership-replace-games");
@@ -716,7 +897,6 @@ mod tests {
("keep.eti", b"keep".as_slice()),
("stale.eti", b"old".as_slice()),
("nested/stale.bin", b"old".as_slice()),
("new.eti", b"new".as_slice()),
("notes.txt", b"user".as_slice()),
("local/save.dat", b"save".as_slice()),
] {
@@ -780,6 +960,144 @@ mod tests {
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_files.is_empty());
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_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(&download_ownership_path(state.path(), "game"), bytes);
}
let error = remove_downloaded_payload(games.path(), state.path(), "game")
.await
.expect_err("ambiguous payload must not be removed");
assert!(error.to_string().contains("cannot safely remove"));
assert_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_files.is_empty());
assert!(record.pending_files.is_none());
}
#[tokio::test]
async fn recovery_handles_every_durable_journal_state() {
// Crash after parking the old sentinel but before publishing pending.
@@ -945,6 +1263,32 @@ mod tests {
assert!(!root.join("archive.eti").exists());
}
#[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).await;
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");