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:
@@ -185,10 +185,12 @@ Reserved per-game paths:
|
|||||||
- `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership
|
- `.lanspread_owned` inside `.local.*` directories proves Lanspread ownership
|
||||||
when the current intent is `None`.
|
when the current intent is `None`.
|
||||||
|
|
||||||
Downloaded-file removal is not an uninstall transaction. It removes the whole
|
Downloaded-file removal is not an uninstall transaction. For a catalog ID that
|
||||||
game root only for a catalog ID that is a single direct child of the configured
|
is a single direct child of the configured game directory, it requires a valid
|
||||||
game directory, has a regular root-level `version.ini`, and has no `local/`,
|
ownership record and regular root-level `version.ini`, and refuses `local/`,
|
||||||
`.local.installing/`, or `.local.backup/` path.
|
`.local.installing/`, or `.local.backup/`. It journals an empty pending
|
||||||
|
generation, deletes only committed downloader-owned files and the sentinel, and
|
||||||
|
keeps the game root plus every unknown file or directory.
|
||||||
|
|
||||||
Recovery reads app-state `install_intent.json` and combines the recorded intent
|
Recovery reads app-state `install_intent.json` and combines the recorded intent
|
||||||
with the observed `local/`, `.local.installing/`, and `.local.backup/` state.
|
with the observed `local/`, `.local.installing/`, and `.local.backup/` state.
|
||||||
@@ -214,6 +216,8 @@ Most scans become O(number of game dirs), with full recursion only when needed.
|
|||||||
`ValidatedDownloadManifest` before any destination mutation. It contains
|
`ValidatedDownloadManifest` before any destination mutation. It contains
|
||||||
canonical game-root-relative paths and rejects aliases, reserved state, shape
|
canonical game-root-relative paths and rejects aliases, reserved state, shape
|
||||||
conflicts, and bounded-size violations as one unit.
|
conflicts, and bounded-size violations as one unit.
|
||||||
|
- A new manifest target that already exists without prior committed ownership is
|
||||||
|
rejected before the sentinel, ownership journal, or payload is mutated.
|
||||||
- Download mutation holds a capability handle for the direct catalog game root.
|
- Download mutation holds a capability handle for the direct catalog game root.
|
||||||
Directory components and final files are reopened relative to that handle
|
Directory components and final files are reopened relative to that handle
|
||||||
without following links or Windows reparse points; chunk writes and checks use
|
without following links or Windows reparse points; chunk writes and checks use
|
||||||
|
|||||||
@@ -127,22 +127,22 @@ as the single source of truth for whether a download is still running.
|
|||||||
|
|
||||||
### Install Transactions
|
### Install Transactions
|
||||||
|
|
||||||
Install, update, uninstall, downloaded-file removal, and startup recovery live
|
Install, update, uninstall, and install-side startup recovery live under
|
||||||
under `src/install/`. Install-side operation intent is stored atomically under
|
`src/install/`. Install-side operation intent is stored atomically under the
|
||||||
the configured peer state directory, at `games/<game_id>/install_intent.json`.
|
configured peer state directory, at `games/<game_id>/install_intent.json`. Game
|
||||||
Game roots still use Lanspread-owned `.local.installing/` and `.local.backup/`
|
roots still use Lanspread-owned `.local.installing/` and `.local.backup/`
|
||||||
directories marked by `.lanspread_owned`. Startup recovery combines the recorded
|
directories marked by `.lanspread_owned`. Startup recovery combines the recorded
|
||||||
intent with the observed filesystem state and only deletes reserved directories
|
intent with the observed filesystem state and only deletes reserved directories
|
||||||
when intent or marker ownership proves they belong to Lanspread. Downloaded-file
|
when intent or marker ownership proves they belong to Lanspread.
|
||||||
removal is deliberately separate from uninstall: it only accepts catalog IDs
|
|
||||||
that are direct children of the configured game directory, refuses installed or
|
|
||||||
in-flight roots, and deletes the whole game root only after finding a regular
|
|
||||||
root-level `version.ini` sentinel.
|
|
||||||
|
|
||||||
Download provenance is stored separately at
|
Download provenance is stored separately at
|
||||||
`games/<game_id>/download_ownership.json` in the peer state directory. It is
|
`games/<game_id>/download_ownership.json` in the peer state directory. It is
|
||||||
bound to the canonical configured games directory so switching library roots
|
bound to the canonical configured games directory so switching library roots
|
||||||
cannot make an old record authorize deletion in a different tree.
|
cannot make an old record authorize deletion in a different tree. Downloaded-
|
||||||
|
file removal is deliberately separate from uninstall: it refuses installed or
|
||||||
|
in-flight roots, journals an empty pending generation, and deletes only the
|
||||||
|
regular sentinel plus paths proven by the last committed ownership set. Unknown
|
||||||
|
files, directories, and the game root remain untouched.
|
||||||
|
|
||||||
Legacy launcher-owned files in game directories are migrated by a dedicated
|
Legacy launcher-owned files in game directories are migrated by a dedicated
|
||||||
pre-start phase. Normal install, recovery, scan, and transfer paths use only the
|
pre-start phase. Normal install, recovery, scan, and transfer paths use only the
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ impl ConfinedGameRoot {
|
|||||||
}
|
}
|
||||||
for parent in parent_directories {
|
for parent in parent_directories {
|
||||||
let parent = ValidatedDownloadPath::from_ownership(&parent)?;
|
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)?;
|
sync_directory_handle(&root.inner.game_root)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -164,6 +165,20 @@ impl ConfinedGameRoot {
|
|||||||
.await?
|
.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> {
|
pub(super) async fn root_regular_file_exists(&self, name: &'static str) -> eyre::Result<bool> {
|
||||||
let root = self.clone();
|
let root = self.clone();
|
||||||
tokio::task::spawn_blocking(
|
tokio::task::spawn_blocking(
|
||||||
@@ -182,6 +197,18 @@ impl ConfinedGameRoot {
|
|||||||
.await?
|
.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> {
|
pub(super) async fn create_new_root_file(&self, name: &'static str) -> eyre::Result<File> {
|
||||||
let root = self.clone();
|
let root = self.clone();
|
||||||
tokio::task::spawn_blocking(move || root.create_new_root_file_blocking(name)).await?
|
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<()> {
|
pub(super) async fn remove_root_file_if_exists(&self, name: &'static str) -> eyre::Result<()> {
|
||||||
let root = self.clone();
|
let root = self.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
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)) {
|
match fs::remove_file(&root.inner.game_root, Path::new(name)) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
sync_directory_handle(&root.inner.game_root)?;
|
sync_directory_handle(&root.inner.game_root)?;
|
||||||
@@ -305,24 +346,56 @@ impl ConfinedGameRoot {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opening and inspecting the same object before unlink catches Windows
|
// Windows has non-symlink reparse points that require same-handle
|
||||||
// reparse points that are not reported as ordinary symlinks.
|
// inspection. On Unix, opening merely to unlink would incorrectly make
|
||||||
let file = match inspect_regular_file_at(&parent, leaf) {
|
// cleanup depend on the owned file's read permission.
|
||||||
Ok(file) => file,
|
#[cfg(windows)]
|
||||||
Err(error) if is_preservable_shape_error(&error) => {
|
{
|
||||||
log::warn!(
|
let file = match inspect_regular_file_at(&parent, leaf) {
|
||||||
"Preserving owned path whose final object changed at {}/{}",
|
Ok(file) => file,
|
||||||
self.inner.display_path.display(),
|
Err(error) if is_preservable_shape_error(&error) => {
|
||||||
path.canonical()
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
Err(error) => return Err(error),
|
Err(error) => return Err(error),
|
||||||
};
|
};
|
||||||
drop(file);
|
|
||||||
fs::remove_file(&parent, Path::new(leaf))?;
|
match fs::stat(&parent, Path::new(leaf), FollowSymlinks::No) {
|
||||||
sync_directory_handle(&parent)?;
|
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||||
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>(
|
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)
|
Ok(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
fn inspect_regular_file_at(parent: &File, leaf: &str) -> eyre::Result<File> {
|
fn inspect_regular_file_at(parent: &File, leaf: &str) -> eyre::Result<File> {
|
||||||
let options = inspection_file_options();
|
let options = inspection_file_options();
|
||||||
let file = fs::open(parent, Path::new(leaf), &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(())
|
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))]
|
#[cfg(not(windows))]
|
||||||
#[allow(clippy::unnecessary_wraps)]
|
#[allow(clippy::unnecessary_wraps)]
|
||||||
const fn reject_windows_reparse(
|
const fn reject_windows_reparse(
|
||||||
@@ -486,11 +574,15 @@ const fn reject_windows_reparse(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
fn is_preservable_shape_error(error: &eyre::Report) -> bool {
|
fn is_preservable_shape_error(error: &eyre::Report) -> bool {
|
||||||
error.downcast_ref::<std::io::Error>().is_some_and(|error| {
|
error.downcast_ref::<std::io::Error>().is_some_and(|error| {
|
||||||
matches!(
|
matches!(
|
||||||
error.kind(),
|
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());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ enum EntryShape {
|
|||||||
Directory,
|
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('\\') {
|
if game_id.contains('/') || game_id.contains('\\') {
|
||||||
eyre::bail!("catalog game ID must be one path component: {game_id}");
|
eyre::bail!("catalog game ID must be one path component: {game_id}");
|
||||||
}
|
}
|
||||||
@@ -406,7 +406,7 @@ fn validate_protocol_game_id(
|
|||||||
Ok(())
|
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() {
|
if !games_folder.is_absolute() {
|
||||||
eyre::bail!(
|
eyre::bail!(
|
||||||
"configured games directory must be absolute: {}",
|
"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}");
|
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) {
|
if is_windows_device_name(device_stem) {
|
||||||
eyre::bail!("download path uses a Windows device name: {component}");
|
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")
|
.strip_prefix("COM")
|
||||||
.or_else(|| upper.strip_prefix("LPT"))
|
.or_else(|| upper.strip_prefix("LPT"))
|
||||||
.is_some_and(|number| {
|
.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, "¹" | "²" | "³")
|
|| matches!(number, "¹" | "²" | "³")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -812,8 +815,12 @@ mod tests {
|
|||||||
let temp = TempDir::new("lanspread-manifest-portability");
|
let temp = TempDir::new("lanspread-manifest-portability");
|
||||||
for path in [
|
for path in [
|
||||||
"game/.ſync/state",
|
"game/.ſync/state",
|
||||||
|
"game/COM0",
|
||||||
|
"game/LPT0.txt",
|
||||||
"game/COM¹.txt",
|
"game/COM¹.txt",
|
||||||
"game/LPT³.log",
|
"game/LPT³.log",
|
||||||
|
"game/CON .txt",
|
||||||
|
"game/CON\u{00a0}",
|
||||||
"game/LOCAL~1/save.dat",
|
"game/LOCAL~1/save.dat",
|
||||||
] {
|
] {
|
||||||
let descriptions = vec![file(path, 1), file("game/version.ini", 8)];
|
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("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/local/save.dat", 1)],
|
||||||
vec![file("game/version.ini", 8), file("game/.sync/state", 1)],
|
vec![file("game/version.ini", 8), file("game/.sync/state", 1)],
|
||||||
|
vec![file("game/version.ini", 8), file("game/COM0", 1)],
|
||||||
|
vec![file("game/version.ini", 8), file("game/CON\u{00a0}", 1)],
|
||||||
vec![file("game/version.ini", 8), file("game/../escape", 1)],
|
vec![file("game/version.ini", 8), file("game/../escape", 1)],
|
||||||
vec![
|
vec![
|
||||||
file("game/version.ini", 8),
|
file("game/version.ini", 8),
|
||||||
|
|||||||
@@ -13,4 +13,6 @@ mod version_ini;
|
|||||||
|
|
||||||
pub(crate) use manifest::{ValidatedDownloadManifest, validate_protocol_v7_descriptions};
|
pub(crate) use manifest::{ValidatedDownloadManifest, validate_protocol_v7_descriptions};
|
||||||
pub(crate) use orchestrator::download_game_files;
|
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};
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ use super::{
|
|||||||
MAX_DOWNLOAD_RELATIVE_PATH_BYTES,
|
MAX_DOWNLOAD_RELATIVE_PATH_BYTES,
|
||||||
ValidatedDownloadManifest,
|
ValidatedDownloadManifest,
|
||||||
ValidatedDownloadPath,
|
ValidatedDownloadPath,
|
||||||
|
canonical_games_folder,
|
||||||
|
validate_game_id,
|
||||||
validate_owned_file_path,
|
validate_owned_file_path,
|
||||||
},
|
},
|
||||||
version_ini::{
|
version_ini::{
|
||||||
@@ -25,7 +27,10 @@ use super::{
|
|||||||
restore_unjournaled_version_ini_transaction,
|
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 OWNERSHIP_SCHEMA_VERSION: u32 = 1;
|
||||||
const MAX_OWNERSHIP_RECORD_BYTES: u64 = 128 * 1024 * 1024;
|
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 record_path = download_ownership_path(state_dir, manifest.game_id());
|
||||||
let tmp_path = download_ownership_tmp_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
|
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(),
|
LoadedOwnership::Valid(record) => {
|
||||||
LoadedOwnership::Missing | LoadedOwnership::Invalid => {
|
(record.committed_files.into_iter().collect(), false)
|
||||||
let baseline =
|
}
|
||||||
DownloadOwnershipRecord::empty(manifest.game_id(), &games_folder_key);
|
LoadedOwnership::Missing | LoadedOwnership::Invalid => (BTreeSet::new(), true),
|
||||||
require_durable_record(
|
};
|
||||||
write_record(&record_path, &tmp_path, &baseline).await?,
|
|
||||||
"download ownership baseline",
|
|
||||||
)?;
|
|
||||||
BTreeSet::new()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let current = manifest.owned_file_paths().into_iter().collect();
|
let current = manifest.owned_file_paths().into_iter().collect();
|
||||||
validate_generation_aliases(&previous, ¤t)?;
|
validate_generation_aliases(&previous, ¤t)?;
|
||||||
|
let untracked_targets = current
|
||||||
|
.difference(&previous)
|
||||||
|
.map(|path| ValidatedDownloadPath::from_ownership(path))
|
||||||
|
.collect::<eyre::Result<Vec<_>>>()?;
|
||||||
|
game_root
|
||||||
|
.reject_existing_unowned_files(untracked_targets)
|
||||||
|
.await?;
|
||||||
|
if needs_baseline {
|
||||||
|
let baseline = DownloadOwnershipRecord::empty(manifest.game_id(), &games_folder_key);
|
||||||
|
require_durable_record(
|
||||||
|
write_record(&record_path, &tmp_path, &baseline).await?,
|
||||||
|
"download ownership baseline",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
record_path,
|
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.
|
/// Publishes the proposed set after the old sentinel has been parked.
|
||||||
pub(super) async fn journal_pending(&self) -> eyre::Result<OwnershipJournalPublication> {
|
pub(super) async fn journal_pending(&self) -> eyre::Result<OwnershipJournalPublication> {
|
||||||
let record = DownloadOwnershipRecord {
|
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.
|
/// Recovers the ownership/version transaction for one inactive game root.
|
||||||
pub(crate) async fn recover_incomplete_download(
|
pub(crate) async fn recover_incomplete_download(
|
||||||
game_root: &Path,
|
game_root: &Path,
|
||||||
@@ -298,19 +432,6 @@ async fn recover_incomplete_download_with_root(
|
|||||||
Ok(())
|
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<()> {
|
fn validate_file_set(paths: &[String]) -> eyre::Result<()> {
|
||||||
if paths.len() > MAX_DOWNLOAD_MANIFEST_ENTRIES {
|
if paths.len() > MAX_DOWNLOAD_MANIFEST_ENTRIES {
|
||||||
eyre::bail!("download ownership has too many paths");
|
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)]
|
#[cfg(unix)]
|
||||||
fn games_folder_key(path: &Path) -> String {
|
fn games_folder_key(path: &Path) -> String {
|
||||||
use std::os::unix::ffi::OsStrExt;
|
use std::os::unix::ffi::OsStrExt;
|
||||||
@@ -707,6 +856,38 @@ mod tests {
|
|||||||
assert!(valid.validate("game", "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).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]
|
#[tokio::test]
|
||||||
async fn replacement_and_abort_touch_only_explicitly_owned_paths() {
|
async fn replacement_and_abort_touch_only_explicitly_owned_paths() {
|
||||||
let games = TempDir::new("lanspread-ownership-replace-games");
|
let games = TempDir::new("lanspread-ownership-replace-games");
|
||||||
@@ -716,7 +897,6 @@ mod tests {
|
|||||||
("keep.eti", b"keep".as_slice()),
|
("keep.eti", b"keep".as_slice()),
|
||||||
("stale.eti", b"old".as_slice()),
|
("stale.eti", b"old".as_slice()),
|
||||||
("nested/stale.bin", b"old".as_slice()),
|
("nested/stale.bin", b"old".as_slice()),
|
||||||
("new.eti", b"new".as_slice()),
|
|
||||||
("notes.txt", b"user".as_slice()),
|
("notes.txt", b"user".as_slice()),
|
||||||
("local/save.dat", b"save".as_slice()),
|
("local/save.dat", b"save".as_slice()),
|
||||||
] {
|
] {
|
||||||
@@ -780,6 +960,144 @@ mod tests {
|
|||||||
assert!(record.pending_files.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_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]
|
#[tokio::test]
|
||||||
async fn recovery_handles_every_durable_journal_state() {
|
async fn recovery_handles_every_durable_journal_state() {
|
||||||
// Crash after parking the old sentinel but before publishing pending.
|
// Crash after parking the old sentinel but before publishing pending.
|
||||||
@@ -945,6 +1263,32 @@ mod tests {
|
|||||||
assert!(!root.join("archive.eti").exists());
|
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]
|
#[tokio::test]
|
||||||
async fn corrupt_or_wrong_root_state_never_authorizes_payload_deletion() {
|
async fn corrupt_or_wrong_root_state_never_authorizes_payload_deletion() {
|
||||||
let games_a = TempDir::new("lanspread-ownership-binding-a");
|
let games_a = TempDir::new("lanspread-ownership-binding-a");
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ use crate::{
|
|||||||
context::{Ctx, OperationGuard, OperationKind},
|
context::{Ctx, OperationGuard, OperationKind},
|
||||||
download::{
|
download::{
|
||||||
ValidatedDownloadManifest,
|
ValidatedDownloadManifest,
|
||||||
clear_download_ownership,
|
|
||||||
download_game_files,
|
download_game_files,
|
||||||
|
remove_downloaded_payload,
|
||||||
validate_protocol_v7_descriptions,
|
validate_protocol_v7_descriptions,
|
||||||
},
|
},
|
||||||
events,
|
events,
|
||||||
@@ -1097,11 +1097,7 @@ async fn run_remove_downloaded_operation(
|
|||||||
ctx.active_operations.clone(),
|
ctx.active_operations.clone(),
|
||||||
tx_notify_ui.clone(),
|
tx_notify_ui.clone(),
|
||||||
);
|
);
|
||||||
let result = async {
|
let result = remove_downloaded_payload(&game_dir, ctx.state_dir.as_ref(), &id).await;
|
||||||
install::remove_downloaded(&game_dir, &id).await?;
|
|
||||||
clear_download_ownership(ctx.state_dir.as_ref(), &id).await
|
|
||||||
}
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -2288,6 +2284,13 @@ mod tests {
|
|||||||
write_file(&root.join("game.eti"), b"archive");
|
write_file(&root.join("game.eti"), b"archive");
|
||||||
|
|
||||||
let ctx = test_ctx(temp.path().to_path_buf());
|
let ctx = test_ctx(temp.path().to_path_buf());
|
||||||
|
crate::download::seed_download_ownership_for_test(
|
||||||
|
ctx.state_dir.as_ref(),
|
||||||
|
temp.path(),
|
||||||
|
"game",
|
||||||
|
&["game.eti"],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
let catalog = ctx.catalog.read().await.clone();
|
let catalog = ctx.catalog.read().await.clone();
|
||||||
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
let scan = scan_local_library(temp.path(), ctx.state_dir.as_ref(), &catalog)
|
||||||
@@ -2311,7 +2314,9 @@ mod tests {
|
|||||||
recv_event(&mut rx).await,
|
recv_event(&mut rx).await,
|
||||||
PeerEvent::RemoveDownloadedGameFinished { id } if id == "game"
|
PeerEvent::RemoveDownloadedGameFinished { id } if id == "game"
|
||||||
));
|
));
|
||||||
assert!(!root.exists());
|
assert!(root.is_dir());
|
||||||
|
assert!(!root.join("version.ini").exists());
|
||||||
|
assert!(!root.join("game.eti").exists());
|
||||||
assert!(ctx.active_operations.read().await.is_empty());
|
assert!(ctx.active_operations.read().await.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
pub(crate) mod intent;
|
pub(crate) mod intent;
|
||||||
mod remove;
|
|
||||||
mod transaction;
|
mod transaction;
|
||||||
pub mod unpack;
|
pub mod unpack;
|
||||||
|
|
||||||
pub use remove::remove_downloaded;
|
|
||||||
pub(crate) use transaction::root_eti_archives;
|
pub(crate) use transaction::root_eti_archives;
|
||||||
pub use transaction::{
|
pub use transaction::{
|
||||||
StreamedInstallTransaction,
|
StreamedInstallTransaction,
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
use std::{
|
|
||||||
ffi::OsStr,
|
|
||||||
io::ErrorKind,
|
|
||||||
path::{Component, Path, PathBuf},
|
|
||||||
};
|
|
||||||
|
|
||||||
use eyre::{WrapErr, bail};
|
|
||||||
|
|
||||||
use crate::game_paths::{BACKUP_DIR, INSTALLING_DIR, LOCAL_DIR, VERSION_INI};
|
|
||||||
|
|
||||||
/// Remove the downloaded files for an uninstalled game root.
|
|
||||||
///
|
|
||||||
/// This is intentionally stricter than the scanner: callers must pass a catalog
|
|
||||||
/// id that is a single path component, the target must be a direct child of the
|
|
||||||
/// configured game directory, and the root must still look like a downloaded
|
|
||||||
/// but uninstalled game immediately before recursive deletion.
|
|
||||||
pub async fn remove_downloaded(game_dir: &Path, id: &str) -> eyre::Result<()> {
|
|
||||||
validate_game_id(id)?;
|
|
||||||
|
|
||||||
let game_dir = canonical_game_dir(game_dir).await?;
|
|
||||||
let game_root = game_dir.join(id);
|
|
||||||
let Some(root_metadata) = symlink_metadata_if_exists(&game_root).await? else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
if root_metadata.file_type().is_symlink() {
|
|
||||||
bail!(
|
|
||||||
"refusing to remove symlink game root {}",
|
|
||||||
game_root.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if !root_metadata.is_dir() {
|
|
||||||
bail!(
|
|
||||||
"refusing to remove non-directory game root {}",
|
|
||||||
game_root.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let game_root = tokio::fs::canonicalize(&game_root)
|
|
||||||
.await
|
|
||||||
.wrap_err_with(|| format!("failed to canonicalize {}", game_root.display()))?;
|
|
||||||
ensure_direct_child(&game_dir, &game_root, id)?;
|
|
||||||
ensure_downloaded_uninstalled_root(&game_root).await?;
|
|
||||||
|
|
||||||
tokio::fs::remove_dir_all(&game_root)
|
|
||||||
.await
|
|
||||||
.wrap_err_with(|| format!("failed to remove downloaded game {}", game_root.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_game_id(id: &str) -> eyre::Result<()> {
|
|
||||||
let mut components = Path::new(id).components();
|
|
||||||
match (components.next(), components.next()) {
|
|
||||||
(Some(Component::Normal(_)), None) => Ok(()),
|
|
||||||
_ => bail!("refusing to remove invalid game id {id:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn canonical_game_dir(game_dir: &Path) -> eyre::Result<PathBuf> {
|
|
||||||
let game_dir = tokio::fs::canonicalize(game_dir)
|
|
||||||
.await
|
|
||||||
.wrap_err_with(|| format!("failed to canonicalize game dir {}", game_dir.display()))?;
|
|
||||||
let metadata = tokio::fs::metadata(&game_dir).await?;
|
|
||||||
if !metadata.is_dir() {
|
|
||||||
bail!("game dir is not a directory: {}", game_dir.display());
|
|
||||||
}
|
|
||||||
Ok(game_dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_direct_child(game_dir: &Path, game_root: &Path, id: &str) -> eyre::Result<()> {
|
|
||||||
if game_root == game_dir
|
|
||||||
|| game_root.parent() != Some(game_dir)
|
|
||||||
|| game_root.file_name() != Some(OsStr::new(id))
|
|
||||||
{
|
|
||||||
bail!(
|
|
||||||
"refusing to remove game root outside direct game-dir child: {}",
|
|
||||||
game_root.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_downloaded_uninstalled_root(game_root: &Path) -> eyre::Result<()> {
|
|
||||||
let version_path = game_root.join(VERSION_INI);
|
|
||||||
let version_metadata = tokio::fs::symlink_metadata(&version_path)
|
|
||||||
.await
|
|
||||||
.wrap_err_with(|| format!("download sentinel is missing: {}", version_path.display()))?;
|
|
||||||
if version_metadata.file_type().is_symlink() || !version_metadata.is_file() {
|
|
||||||
bail!(
|
|
||||||
"refusing to remove game without a regular version.ini sentinel: {}",
|
|
||||||
game_root.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_absent(&game_root.join(LOCAL_DIR), "local install").await?;
|
|
||||||
ensure_absent(&game_root.join(INSTALLING_DIR), "install staging").await?;
|
|
||||||
ensure_absent(&game_root.join(BACKUP_DIR), "install backup").await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_absent(path: &Path, label: &str) -> eyre::Result<()> {
|
|
||||||
if symlink_metadata_if_exists(path).await?.is_some() {
|
|
||||||
bail!(
|
|
||||||
"refusing to remove game root with {label}: {}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn symlink_metadata_if_exists(path: &Path) -> eyre::Result<Option<std::fs::Metadata>> {
|
|
||||||
match tokio::fs::symlink_metadata(path).await {
|
|
||||||
Ok(metadata) => Ok(Some(metadata)),
|
|
||||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
|
|
||||||
Err(err) => Err(err.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::test_support::TempDir;
|
|
||||||
|
|
||||||
fn write_file(path: &Path, bytes: &[u8]) {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).expect("parent dir should be created");
|
|
||||||
}
|
|
||||||
std::fs::write(path, bytes).expect("file should be written");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn remove_downloaded_deletes_only_requested_game_root() {
|
|
||||||
let temp = TempDir::new("lanspread-remove-download");
|
|
||||||
let root = temp.game_root();
|
|
||||||
let sibling = temp.path().join("sibling");
|
|
||||||
write_file(&root.join("version.ini"), b"20250101");
|
|
||||||
write_file(&root.join("game.eti"), b"archive");
|
|
||||||
write_file(&sibling.join("version.ini"), b"20250101");
|
|
||||||
|
|
||||||
remove_downloaded(temp.path(), "game")
|
|
||||||
.await
|
|
||||||
.expect("downloaded game root should be removed");
|
|
||||||
|
|
||||||
assert!(!root.exists());
|
|
||||||
assert!(sibling.join("version.ini").is_file());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn remove_downloaded_refuses_installed_game() {
|
|
||||||
let temp = TempDir::new("lanspread-remove-installed");
|
|
||||||
let root = temp.game_root();
|
|
||||||
write_file(&root.join("version.ini"), b"20250101");
|
|
||||||
write_file(&root.join("local").join("payload.txt"), b"installed");
|
|
||||||
|
|
||||||
let err = remove_downloaded(temp.path(), "game")
|
|
||||||
.await
|
|
||||||
.expect_err("installed game must not be removed");
|
|
||||||
|
|
||||||
assert!(err.to_string().contains("local install"));
|
|
||||||
assert!(root.join("version.ini").is_file());
|
|
||||||
assert!(root.join("local").join("payload.txt").is_file());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn remove_downloaded_refuses_missing_download_sentinel() {
|
|
||||||
let temp = TempDir::new("lanspread-remove-missing-sentinel");
|
|
||||||
let root = temp.game_root();
|
|
||||||
write_file(&root.join("game.eti"), b"archive");
|
|
||||||
|
|
||||||
let err = remove_downloaded(temp.path(), "game")
|
|
||||||
.await
|
|
||||||
.expect_err("undownloaded game root must not be removed");
|
|
||||||
|
|
||||||
assert!(err.to_string().contains("download sentinel is missing"));
|
|
||||||
assert!(root.join("game.eti").is_file());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn remove_downloaded_rejects_path_traversal_id() {
|
|
||||||
let temp = TempDir::new("lanspread-remove-traversal");
|
|
||||||
let root = temp.game_root();
|
|
||||||
write_file(&root.join("version.ini"), b"20250101");
|
|
||||||
|
|
||||||
let err = remove_downloaded(temp.path(), "../game")
|
|
||||||
.await
|
|
||||||
.expect_err("path traversal id must be rejected");
|
|
||||||
|
|
||||||
assert!(err.to_string().contains("invalid game id"));
|
|
||||||
assert!(root.join("version.ini").is_file());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn remove_downloaded_refuses_symlink_game_root() {
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
|
|
||||||
let temp = TempDir::new("lanspread-remove-symlink");
|
|
||||||
let outside = temp.path().join("outside");
|
|
||||||
write_file(&outside.join("version.ini"), b"20250101");
|
|
||||||
symlink(&outside, temp.game_root()).expect("symlink should be created");
|
|
||||||
|
|
||||||
let err = remove_downloaded(temp.path(), "game")
|
|
||||||
.await
|
|
||||||
.expect_err("symlink game root must be rejected");
|
|
||||||
|
|
||||||
assert!(err.to_string().contains("symlink game root"));
|
|
||||||
assert!(outside.join("version.ini").is_file());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -114,27 +114,31 @@ Alternatives:
|
|||||||
promotion point, but can require another full game's worth of disk and a large
|
promotion point, but can require another full game's worth of disk and a large
|
||||||
multi-file transaction mechanism.
|
multi-file transaction mechanism.
|
||||||
|
|
||||||
## 2026-08-09 — Manifest target paths become download-owned
|
## 2026-08-09 — Reject untracked exact manifest targets
|
||||||
|
|
||||||
**TL;DR:** Once a validated download transaction starts, every regular file path
|
**TL;DR:** Before parking `version.ini` or preparing storage, reject a download
|
||||||
in that manifest is treated as downloader-owned for abort and recovery. Unknown
|
when a regular-file destination already exists but is absent from the last
|
||||||
paths outside the manifest remain untouched.
|
committed ownership set. Previously owned paths may be replaced; every unknown
|
||||||
|
path remains untouched.
|
||||||
|
|
||||||
Without prior ownership state, an existing file at an exact manifest target is
|
Without prior ownership state, an existing file at an exact manifest target is
|
||||||
ambiguous. Treating the authoritative target namespace as download-owned keeps
|
ambiguous. Automatically claiming it would make a failed first or post-upgrade
|
||||||
legacy upgrades working and makes partial-transfer cleanup deterministic. A
|
download truncate and then delete potentially user-owned bytes. Failing closed
|
||||||
user-created extra file is preserved, but a user file placed at an exact package
|
preserves the file, but a legacy tree without an ownership journal may require
|
||||||
path may be replaced or removed by the download.
|
the user to move or remove conflicting package files before its first update.
|
||||||
|
Phase 2 can later recognize intact catalog bytes by their trusted local hashes
|
||||||
|
without weakening this boundary.
|
||||||
|
|
||||||
Alternatives:
|
Alternatives:
|
||||||
|
|
||||||
- Reject every untracked existing target. This preserves ambiguous files, but
|
- Treat every validated manifest target as download-owned. This keeps legacy
|
||||||
prevents the first post-upgrade update of existing legacy downloads.
|
upgrades seamless, but can destroy an unrelated user file on failure or
|
||||||
|
cancellation.
|
||||||
- Snapshot and restore untracked targets. This preserves their bytes, but adds
|
- Snapshot and restore untracked targets. This preserves their bytes, but adds
|
||||||
unbounded backup space and another crash-consistent transaction.
|
unbounded backup space and another crash-consistent transaction.
|
||||||
- Leave pre-existing untracked targets after abort. This avoids deletion, but
|
- Stage every payload in a second game tree and promote it atomically. This
|
||||||
the transfer may already have truncated or partially overwritten them, so it
|
avoids touching collisions during transfer, but can require another full
|
||||||
does not actually preserve their contents.
|
game's worth of disk and a cross-platform multi-file promotion protocol.
|
||||||
|
|
||||||
## 2026-08-09 — Ownership-record rename is its publication point
|
## 2026-08-09 — Ownership-record rename is its publication point
|
||||||
|
|
||||||
@@ -201,6 +205,66 @@ Alternatives:
|
|||||||
but expands the crash protocol and still needs a policy for pre-existing
|
but expands the crash protocol and still needs a policy for pre-existing
|
||||||
manifest directories.
|
manifest directories.
|
||||||
|
|
||||||
|
## 2026-08-09 — Remove downloads as an empty ownership generation
|
||||||
|
|
||||||
|
**TL;DR:** “Remove downloaded files” parks `version.ini`, durably journals an
|
||||||
|
empty pending generation, removes only the last committed file set, discards the
|
||||||
|
sentinel, and keeps a valid empty ownership record. It never recursively deletes
|
||||||
|
the game root.
|
||||||
|
|
||||||
|
The existing pending-generation recovery already gives this operation a clean
|
||||||
|
crash protocol. Before the empty generation is durable, recovery restores the
|
||||||
|
sentinel and no payload has been touched. Afterwards, recovery idempotently
|
||||||
|
finishes deleting only proven-owned files and finalizes the empty record.
|
||||||
|
Unknown files and directories remain. A legacy, corrupt, or wrongly bound record
|
||||||
|
fails closed because paths and sizes alone cannot distinguish package bytes from
|
||||||
|
user bytes; Phase 2 can later adopt an intact legacy tree by checking it against
|
||||||
|
trusted catalog hashes.
|
||||||
|
|
||||||
|
Alternatives:
|
||||||
|
|
||||||
|
- Recursively delete the game root, then clear the ledger. This frees every
|
||||||
|
byte, but destroys unknown files and leaves stale deletion authority if the
|
||||||
|
process crashes between those operations.
|
||||||
|
- Delete the sentinel and leave every ambiguous payload file. This preserves
|
||||||
|
user bytes, but reports a misleading successful removal while reclaiming
|
||||||
|
almost no space.
|
||||||
|
- Infer ownership from the current remote manifest or filename extensions. This
|
||||||
|
is convenient for legacy trees, but lets untrusted or incomplete metadata
|
||||||
|
authorize deletion.
|
||||||
|
|
||||||
|
## 2026-08-09 — Ownership follows committed paths, not inode generations
|
||||||
|
|
||||||
|
**TL;DR:** A successfully committed download path remains downloader-owned until
|
||||||
|
a later download or explicit removal releases it. The ledger does not persist
|
||||||
|
platform-specific inode or file-ID generations.
|
||||||
|
|
||||||
|
This is enough for the stated remote-peer threat model: a peer cannot replace a
|
||||||
|
victim filesystem object except through the one admitted download operation, and
|
||||||
|
untracked exact targets are rejected before that operation starts. A local actor
|
||||||
|
who replaces an already owned path makes the replacement subject to later
|
||||||
|
owned-path cleanup. That local-filesystem race is outside the plan's threat
|
||||||
|
model, but the consequence is recorded because the provenance boundary is path
|
||||||
|
based rather than object based.
|
||||||
|
|
||||||
|
The same rule applies if someone deletes and recreates the entire game root at
|
||||||
|
the same configured path outside Lanspread: the last committed relative paths
|
||||||
|
remain owned. The application cannot distinguish that replacement without a
|
||||||
|
separate root-generation marker or platform file identity.
|
||||||
|
|
||||||
|
Alternatives:
|
||||||
|
|
||||||
|
- Persist inode/file-ID generations and delete only the exact recorded object.
|
||||||
|
This detects replacement, but creates a platform-specific schema and does not
|
||||||
|
survive ordinary copy/restore workflows consistently.
|
||||||
|
- Hash every owned file before cleanup. Phase 2 catalog hashes can identify
|
||||||
|
intact package content, but always rereading multi-gigabyte payloads solely
|
||||||
|
for deletion adds significant latency and still needs a policy for modified
|
||||||
|
downloader-owned files.
|
||||||
|
- Never delete a previously owned path automatically. This preserves every local
|
||||||
|
replacement but makes stale cleanup and “Remove downloaded files” unable to
|
||||||
|
reclaim ordinary package data.
|
||||||
|
|
||||||
## 2026-08-09 — A baseline record distinguishes new and legacy scratch
|
## 2026-08-09 — A baseline record distinguishes new and legacy scratch
|
||||||
|
|
||||||
**TL;DR:** Before parking an existing `version.ini`, the new downloader durably
|
**TL;DR:** Before parking an existing `version.ini`, the new downloader durably
|
||||||
@@ -299,15 +363,14 @@ Alternatives:
|
|||||||
**TL;DR:** Reject links and reparse points that can redirect path resolution,
|
**TL;DR:** Reject links and reparse points that can redirect path resolution,
|
||||||
but do not reject an otherwise regular manifest target merely because it has
|
but do not reject an otherwise regular manifest target merely because it has
|
||||||
multiple hard links. The threat model excludes an attacker controlling the
|
multiple hard links. The threat model excludes an attacker controlling the
|
||||||
victim filesystem, and exact manifest target paths already become download-owned
|
victim filesystem, and only previously recorded download-owned targets may be
|
||||||
when a transaction starts.
|
replaced by a transaction.
|
||||||
|
|
||||||
A hard link cannot be selected or created by a remote description outside the
|
A hard link cannot be selected or created by a remote description outside the
|
||||||
validated game-relative namespace. A local user can make the same inode visible
|
validated game-relative namespace. A local user can make an already owned inode
|
||||||
under another name, but that is local filesystem manipulation rather than a
|
visible under another name, but that is local filesystem manipulation rather
|
||||||
peer-controlled path escape. This choice inherits the documented consequence
|
than a peer-controlled path escape. New untracked manifest targets are rejected
|
||||||
that replacing an exact ambiguous manifest target may affect another local name
|
before mutation instead of being claimed automatically.
|
||||||
for those bytes.
|
|
||||||
|
|
||||||
Alternatives:
|
Alternatives:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user