mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
fix(windows): avoid repeated firewall prompts in fixtures and launcher updates
Keep same-machine test sockets on loopback and stage launcher-managed games at a stable executable path per game home. Preserve runtime dependencies and protect running installed/native versions from replacement and cleanup. Refs #9685.
This commit is contained in:
1 parent
3c945f9d30
commit
1a6a403884
21 files changed
+414
-27
No files matched your search
@@ -944,9 +944,16 @@ impl Game<'_> {
|
||||
let port = match ctx.get("port") {
|
||||
Some(Arg::Int(p)) => *p,
|
||||
// HttpUtil.getAvailablePort.
|
||||
_ => std::net::TcpListener::bind(("0.0.0.0", 0))
|
||||
.and_then(|l| l.local_addr())
|
||||
.map_or(25564, |a| i32::from(a.port())),
|
||||
_ => std::net::TcpListener::bind((
|
||||
if std::env::var_os("HORIZON_TEST_LOOPBACK").is_some_and(|v| v == "1") {
|
||||
"127.0.0.1"
|
||||
} else {
|
||||
"0.0.0.0"
|
||||
},
|
||||
0,
|
||||
))
|
||||
.and_then(|l| l.local_addr())
|
||||
.map_or(25564, |a| i32::from(a.port())),
|
||||
};
|
||||
let published = if self.serving() {
|
||||
self.published_port()
|
||||
|
||||
@@ -3702,7 +3702,7 @@ mod tests {
|
||||
let sync = std::sync::Arc::new(data::SyncData::empty());
|
||||
let config = NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::lan(0, name.into(), 2)
|
||||
..NetConfig::loopback(0, name.into(), 2)
|
||||
};
|
||||
let lan = Lan::start_with(sync.clone(), config).unwrap();
|
||||
let encoder = ChunkEncoder::new(
|
||||
|
||||
@@ -917,7 +917,7 @@ mod tests {
|
||||
fn hitting_shulker_bullet_sends_15_crit_particles_to_players() {
|
||||
let config = NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::lan(0, "ShulkerBulletTest".into(), 2)
|
||||
..NetConfig::loopback(0, "ShulkerBulletTest".into(), 2)
|
||||
};
|
||||
let mut sync = data::SyncData::empty();
|
||||
sync.builtin.registries.insert(
|
||||
|
||||
@@ -578,7 +578,7 @@ mod hub_tests {
|
||||
fn hub() -> Hub {
|
||||
let config = NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::lan(0, "ArrivalsTest".into(), 4)
|
||||
..NetConfig::loopback(0, "ArrivalsTest".into(), 4)
|
||||
};
|
||||
let mut sync = data::SyncData::empty();
|
||||
// The entity types the tracker names: the player, as id 0.
|
||||
|
||||
@@ -1686,7 +1686,7 @@ mod tests {
|
||||
announce: false,
|
||||
listen: false,
|
||||
accepts_transfers: false,
|
||||
..crate::lan::NetConfig::lan(0, "Transfers".into(), 8)
|
||||
..crate::lan::NetConfig::loopback(0, "Transfers".into(), 8)
|
||||
};
|
||||
let lan =
|
||||
crate::lan::Lan::start_with(Arc::new(crate::lan::data::SyncData::empty()), config)
|
||||
@@ -1771,7 +1771,7 @@ mod tests {
|
||||
let _ = never.recv();
|
||||
Vec::new()
|
||||
}))),
|
||||
..crate::lan::NetConfig::lan(0, "Owner - World".into(), 8)
|
||||
..crate::lan::NetConfig::loopback(0, "Owner - World".into(), 8)
|
||||
};
|
||||
let lan =
|
||||
crate::lan::Lan::start_with(Arc::new(crate::lan::data::SyncData::empty()), config)
|
||||
|
||||
@@ -136,7 +136,11 @@ impl Scanner {
|
||||
pub fn start() -> Self {
|
||||
let games: Games = Arc::default();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
if let Ok(socket) = bind_listener() {
|
||||
// Constructing multiplayer screens in unit tests must not open the LAN.
|
||||
if !cfg!(test)
|
||||
&& !super::test_loopback()
|
||||
&& let Ok(socket) = bind_listener()
|
||||
{
|
||||
let (games, stop) = (games.clone(), stop.clone());
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("horizon-lan-scan".into())
|
||||
|
||||
@@ -4578,7 +4578,7 @@ mod tests {
|
||||
fn hub_with_sync(sync: SyncData) -> (Hub, std::sync::mpsc::Receiver<crate::lan::Out>) {
|
||||
let config = crate::lan::NetConfig {
|
||||
announce: false,
|
||||
..crate::lan::NetConfig::lan(0, "EffectSyncTest".into(), 2)
|
||||
..crate::lan::NetConfig::loopback(0, "EffectSyncTest".into(), 2)
|
||||
};
|
||||
let sync = std::sync::Arc::new(sync);
|
||||
let lan = Lan::start_with(sync.clone(), config).unwrap();
|
||||
|
||||
@@ -218,6 +218,15 @@ pub struct NetConfig {
|
||||
pub levels: crate::chunk::LevelShapes,
|
||||
}
|
||||
impl NetConfig {
|
||||
/// A real TCP server confined to this machine, without multicast announcements.
|
||||
/// Use this for socket tests; `lan` retains the public LAN behaviour.
|
||||
pub fn loopback(port: u16, motd: String, max_players: usize) -> Self {
|
||||
Self {
|
||||
bind: Some(std::net::Ipv4Addr::LOCALHOST.into()),
|
||||
announce: false,
|
||||
..Self::lan(port, motd, max_players)
|
||||
}
|
||||
}
|
||||
/// MinecraftServer.enforceSecureProfile.
|
||||
pub fn enforces_secure_chat(&self) -> bool {
|
||||
self.secure_chat.as_ref().is_some_and(|s| s.enforced())
|
||||
@@ -635,7 +644,13 @@ impl Lan {
|
||||
/// The socket [`Lan::listen`] accepts on, bound ahead so that a failure
|
||||
/// can be reported before the hub is at hand.
|
||||
pub fn bind(address: Option<std::net::IpAddr>, port: u16) -> io::Result<TcpListener> {
|
||||
let bind = address.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
|
||||
let bind = address.unwrap_or_else(|| {
|
||||
if test_loopback() {
|
||||
std::net::Ipv4Addr::LOCALHOST.into()
|
||||
} else {
|
||||
std::net::Ipv4Addr::UNSPECIFIED.into()
|
||||
}
|
||||
});
|
||||
let listener = TcpListener::bind((bind, port))?;
|
||||
listener.set_nonblocking(true)?;
|
||||
Ok(listener)
|
||||
@@ -654,7 +669,7 @@ impl Lan {
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("horizon-lan-accept".into())
|
||||
.spawn(move || accept_loop(listener, accept_shared, &accept_stop))?;
|
||||
self._pinger = if announce {
|
||||
self._pinger = if announce && !test_loopback() {
|
||||
discovery::Pinger::start(self.shared.motd(), port).ok()
|
||||
} else {
|
||||
None
|
||||
@@ -931,13 +946,41 @@ fn handle(stream: TcpStream, addr: SocketAddr, counted: Counted) {
|
||||
|
||||
/// HttpUtil.getAvailablePort: a free local port.
|
||||
pub fn available_port() -> u16 {
|
||||
TcpListener::bind(("0.0.0.0", 0))
|
||||
let bind = if cfg!(test) || test_loopback() {
|
||||
"127.0.0.1"
|
||||
} else {
|
||||
"0.0.0.0"
|
||||
};
|
||||
TcpListener::bind((bind, 0))
|
||||
.and_then(|l| l.local_addr())
|
||||
.map_or(25565, |a| a.port())
|
||||
}
|
||||
|
||||
/// Same-machine harnesses opt in; real LAN runs keep the normal network interfaces.
|
||||
pub(super) fn test_loopback() -> bool {
|
||||
std::env::var_os("HORIZON_TEST_LOOPBACK").is_some_and(|v| v == "1")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_local_server_accepts_only_the_requested_loopback_address() {
|
||||
let lan = super::Lan::start_with(
|
||||
std::sync::Arc::new(super::data::SyncData::empty()),
|
||||
super::NetConfig::loopback(0, "Local test".into(), 1),
|
||||
)
|
||||
.unwrap();
|
||||
let local = std::net::TcpStream::connect(("127.0.0.1", lan.port)).unwrap();
|
||||
let other = "127.0.0.2".parse::<std::net::IpAddr>().unwrap();
|
||||
assert!(
|
||||
std::net::TcpStream::connect_timeout(
|
||||
&std::net::SocketAddr::new(other, lan.port),
|
||||
std::time::Duration::from_millis(500),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
drop(local);
|
||||
}
|
||||
use super::conn::MEMORY_ADDRESS;
|
||||
use super::*;
|
||||
use std::{io::Read, time::Instant};
|
||||
@@ -949,7 +992,7 @@ mod tests {
|
||||
fn a_flood_of_silent_sockets_is_turned_away() {
|
||||
let config = NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::lan(0, "Flood".into(), 1)
|
||||
..NetConfig::loopback(0, "Flood".into(), 1)
|
||||
};
|
||||
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
|
||||
let address = (std::net::Ipv4Addr::LOCALHOST, lan.port);
|
||||
@@ -994,7 +1037,7 @@ mod tests {
|
||||
let config = NetConfig {
|
||||
announce: false,
|
||||
listen: false,
|
||||
..NetConfig::lan(0, "Status".into(), 8)
|
||||
..NetConfig::loopback(0, "Status".into(), 8)
|
||||
};
|
||||
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
|
||||
assert!(lan.shared.try_admit(MEMORY_ADDRESS, 1, "Player").is_none());
|
||||
@@ -1012,7 +1055,7 @@ mod tests {
|
||||
let config = NetConfig {
|
||||
announce: false,
|
||||
listen: false,
|
||||
..NetConfig::lan(0, "Share".into(), 20)
|
||||
..NetConfig::loopback(0, "Share".into(), 20)
|
||||
};
|
||||
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
|
||||
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
|
||||
@@ -1046,7 +1089,7 @@ mod tests {
|
||||
lang: Arc::new(Default::default()),
|
||||
log_ips: true,
|
||||
}),
|
||||
..NetConfig::lan(0, "Room".into(), 1)
|
||||
..NetConfig::loopback(0, "Room".into(), 1)
|
||||
};
|
||||
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
|
||||
let shared = &lan.shared;
|
||||
|
||||
@@ -208,7 +208,7 @@ impl Server {
|
||||
}
|
||||
|
||||
fn server(resources: &Path) -> Server {
|
||||
server_with(resources, NetConfig::lan(0, "Test - World".into(), 8))
|
||||
server_with(resources, NetConfig::loopback(0, "Test - World".into(), 8))
|
||||
}
|
||||
|
||||
fn server_with(resources: &Path, net: NetConfig) -> Server {
|
||||
@@ -295,7 +295,7 @@ fn a_lan_guest_sees_the_signed_in_owners_skin() {
|
||||
let net = NetConfig {
|
||||
announce: false,
|
||||
owner_profile: Some(profile.clone()),
|
||||
..NetConfig::lan(0, "Test - World".into(), 8)
|
||||
..NetConfig::loopback(0, "Test - World".into(), 8)
|
||||
};
|
||||
let mut s = server_with(&resources, net);
|
||||
s.hub.singleplayer_owner = Some(offline_uuid("Owner"));
|
||||
@@ -484,7 +484,7 @@ fn neoforge_shapes_items_reach_two_guests() {
|
||||
sync.id("minecraft:item", "minecraft:raw_copper").unwrap(),
|
||||
sync.id("minecraft:item", "minecraft:redstone").unwrap(),
|
||||
);
|
||||
let mut s = server_sync(sync, NetConfig::lan(0, "Test - World".into(), 8));
|
||||
let mut s = server_sync(sync, NetConfig::loopback(0, "Test - World".into(), 8));
|
||||
let port = s.hub.lan.port;
|
||||
// Guest one joins first and stays while guest two joins and reads its
|
||||
// own join packets, then guest one reads its own.
|
||||
@@ -741,7 +741,7 @@ fn a_signed_in_hosts_lan_authenticates_guests_but_not_its_own_player() {
|
||||
let net = NetConfig {
|
||||
online: Some(Arc::new(OnlineMode::generate(false).unwrap())),
|
||||
announce: false,
|
||||
..NetConfig::lan(0, "Test - World".into(), 8)
|
||||
..NetConfig::loopback(0, "Test - World".into(), 8)
|
||||
};
|
||||
let lan = Lan::start_with(sync, net).unwrap();
|
||||
let guest = Connection::connect("127.0.0.1", lan.port, Duration::from_secs(5)).unwrap();
|
||||
|
||||
@@ -12,6 +12,12 @@ in vanilla's Anvil format.
|
||||
in the README scores each subsystem against vanilla and lists what is still
|
||||
missing; [Rust rewrite status](rust-parity.md) has the details.
|
||||
|
||||
On Windows, the launcher uses a stable executable path for each game home across
|
||||
release and native-mod updates. Its private runtime copy lives under the launcher's
|
||||
data folder in `run/<home key>/game/`; installed versions remain intact. Opening a
|
||||
world to other machines can still require an initial Windows Firewall permission.
|
||||
The launcher does not create firewall rules or change network-profile settings.
|
||||
|
||||
## musl Linux: manual installation
|
||||
|
||||
On x86-64 musl distributions, download `horizon-linux-musl-x86_64.tar.gz` from the
|
||||
|
||||
@@ -4,6 +4,13 @@ How parity work is run, checked and merged. The harnesses themselves are describ
|
||||
|
||||
## Running the vanilla harnesses
|
||||
|
||||
Same-machine fixtures created with `lab.game_env` use `HORIZON_TEST_LOOPBACK=1`: Horizon
|
||||
listens on loopback and does not scan or announce LAN worlds. This avoids Windows Firewall
|
||||
permission prompts for newly compiled fixture executables. Set `HORIZON_TEST_LOOPBACK=0`
|
||||
explicitly when testing real network interfaces or multicast discovery. Local vanilla server
|
||||
fixtures also set `server-ip=127.0.0.1`; use one installed JDK via `JAVA_HOME` and its `bin`
|
||||
on `PATH`, rather than copying Java to each fixture directory. Keep the firewall enabled.
|
||||
|
||||
`tools/parity.py` runs the `tools/test_*_parity.py` harnesses in parallel and reruns only what a change can affect:
|
||||
|
||||
```sh
|
||||
|
||||
+37
-4
@@ -2761,6 +2761,8 @@ pub fn client_command(tag: &str) -> Command {
|
||||
let home = game_home();
|
||||
let _ = fs::create_dir_all(&home);
|
||||
let mut cmd = Command::new(client_program(tag));
|
||||
#[cfg(windows)]
|
||||
crate::windows_runtime::mark(&mut cmd, &version_dir(tag));
|
||||
strip_launcher_render_env(&mut cmd);
|
||||
cmd.current_dir(&home)
|
||||
.env("HORIZON_HOME", &home)
|
||||
@@ -2882,7 +2884,7 @@ fn stale_versions(installed: &[String], keep: usize, protected: &[String]) -> Ve
|
||||
/// their program and `--resources` from it. Windows refuses to delete a running program's files,
|
||||
/// so there the delete itself fails. With no answer from `ps` the version counts as running.
|
||||
#[cfg(unix)]
|
||||
fn in_use(tag: &str) -> bool {
|
||||
pub(crate) fn in_use(tag: &str) -> bool {
|
||||
let dir = format!("{}/", version_dir(tag).display());
|
||||
Command::new("ps")
|
||||
.args(["-A", "-ww", "-o", "args="])
|
||||
@@ -2891,8 +2893,13 @@ fn in_use(tag: &str) -> bool {
|
||||
.is_none_or(|out| String::from_utf8_lossy(&out.stdout).contains(&dir))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn in_use(_tag: &str) -> bool {
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn in_use(tag: &str) -> bool {
|
||||
crate::windows_runtime::in_use(&version_dir(tag))
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(crate) fn in_use(_tag: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -4021,7 +4028,32 @@ impl Running {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn(job: Job, mut cmd: Command, tx: Sender<Msg>) -> Result<Running, String> {
|
||||
/// Prepare a command's Windows runtime and keep its guard alive through process exit.
|
||||
pub struct RuntimeGuard {
|
||||
pub(crate) _slot: fs::File,
|
||||
pub(crate) _source: fs::File,
|
||||
}
|
||||
|
||||
pub fn prepare_command(cmd: Command) -> Result<(Command, Option<RuntimeGuard>), String> {
|
||||
#[cfg(windows)]
|
||||
let cmd = crate::windows_runtime::redirect(cmd);
|
||||
#[cfg(windows)]
|
||||
let guard = crate::windows_runtime::prepare(&cmd).map_err(|e| {
|
||||
crate::i18n::trf(
|
||||
"Could not run {program}: {error}",
|
||||
&[
|
||||
("program", &cmd.get_program().to_string_lossy()),
|
||||
("error", &e.to_string()),
|
||||
],
|
||||
)
|
||||
})?;
|
||||
#[cfg(not(windows))]
|
||||
let guard = None;
|
||||
Ok((cmd, guard))
|
||||
}
|
||||
|
||||
pub fn spawn(job: Job, cmd: Command, tx: Sender<Msg>) -> Result<Running, String> {
|
||||
let (mut cmd, runtime) = prepare_command(cmd)?;
|
||||
crate::process::hide_console(&mut cmd);
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::piped())
|
||||
@@ -4064,6 +4096,7 @@ pub fn spawn(job: Job, mut cmd: Command, tx: Sender<Msg>) -> Result<Running, Str
|
||||
Ok(s) => Err(format!("Exited with {s}")),
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
drop(runtime);
|
||||
let _ = tx.send(Msg::Exit(job, result));
|
||||
});
|
||||
Ok(Running { child, stdin })
|
||||
|
||||
+2
-1
@@ -1212,7 +1212,8 @@ fn json(value: serde_json::Value) {
|
||||
}
|
||||
|
||||
/// Runs a game process in this terminal and reports how it ended.
|
||||
fn attached(mut cmd: Command) -> Result<(), String> {
|
||||
fn attached(cmd: Command) -> Result<(), String> {
|
||||
let (mut cmd, _runtime) = backend::prepare_command(cmd)?;
|
||||
let status = cmd
|
||||
.status()
|
||||
.map_err(|e| format!("Could not start {:?}: {e}", cmd.get_program()))?;
|
||||
|
||||
@@ -459,6 +459,8 @@ pub fn plan(launch: &Launch, words: Vec<OsString>, windows: bool, found: &Found)
|
||||
/// The game's command run with `launch`: a new command with the same folder and variables,
|
||||
/// plus the instance's variables. Call it last, after every argument is added.
|
||||
pub fn wrap(cmd: Command, launch: &Launch) -> Command {
|
||||
#[cfg(windows)]
|
||||
let cmd = crate::windows_runtime::redirect(cmd);
|
||||
if launch.is_empty() {
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -54,4 +54,6 @@ pub mod themes;
|
||||
pub mod uninstall_aside;
|
||||
pub mod update_handoff;
|
||||
pub mod wardrobe;
|
||||
#[cfg(windows)]
|
||||
mod windows_runtime;
|
||||
pub mod worlds;
|
||||
@@ -2542,6 +2542,9 @@ pub fn prune(keep: &str) {
|
||||
})
|
||||
.collect();
|
||||
for old in stale(builds, keep, &played) {
|
||||
if backend::in_use(&old) {
|
||||
continue;
|
||||
}
|
||||
let _ = backend::remove(&old);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,8 +552,14 @@ impl Default for NewServer {
|
||||
|
||||
/// A port nobody listens on, from `from` up, and not one of `taken`.
|
||||
pub fn free_port(from: u16, taken: &[u16]) -> u16 {
|
||||
let bind = if cfg!(test) || std::env::var_os("HORIZON_TEST_LOOPBACK").is_some_and(|v| v == "1")
|
||||
{
|
||||
"127.0.0.1"
|
||||
} else {
|
||||
"0.0.0.0"
|
||||
};
|
||||
(from..u16::MAX)
|
||||
.find(|p| !taken.contains(p) && std::net::TcpListener::bind(("0.0.0.0", *p)).is_ok())
|
||||
.find(|p| !taken.contains(p) && std::net::TcpListener::bind((bind, *p)).is_ok())
|
||||
.unwrap_or(from)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Keep the game's Windows executable path stable across installed versions.
|
||||
//! A slot belongs to one game home; its lock is held until the child exits.
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
|
||||
use std::{fs, io, path::Path, process::Command};
|
||||
|
||||
const SOURCE: &str = "HORIZON_WINDOWS_RUNTIME_SOURCE";
|
||||
const SLOT: &str = "HORIZON_WINDOWS_RUNTIME_SLOT";
|
||||
|
||||
pub(crate) fn mark(cmd: &mut Command, source: &Path) {
|
||||
cmd.env(SOURCE, source);
|
||||
}
|
||||
|
||||
fn value<'a>(cmd: &'a Command, key: &str) -> Option<&'a std::ffi::OsStr> {
|
||||
cmd.get_envs().find(|(k, _)| *k == key).and_then(|(_, v)| v)
|
||||
}
|
||||
|
||||
/// Called before adding a pre-launch command or wrapper, after applying the instance.
|
||||
pub(crate) fn redirect(cmd: Command) -> Command {
|
||||
redirect_in(cmd, &crate::backend::data_dir().join("run"))
|
||||
}
|
||||
|
||||
fn redirect_in(cmd: Command, root: &Path) -> Command {
|
||||
if value(&cmd, SOURCE).is_none() || value(&cmd, SLOT).is_some() {
|
||||
return cmd;
|
||||
}
|
||||
let home = value(&cmd, "HORIZON_HOME")
|
||||
.map(Path::new)
|
||||
.or(cmd.get_current_dir())
|
||||
.expect("the game command has a home");
|
||||
let home = fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf());
|
||||
let key = format!("{:x}", Sha256::digest(home.as_os_str().as_encoded_bytes()));
|
||||
let slot = root.join(key);
|
||||
let mut out = Command::new(slot.join("game").join(crate::backend::CLIENT));
|
||||
out.args(cmd.get_args());
|
||||
if let Some(dir) = cmd.get_current_dir() {
|
||||
out.current_dir(dir);
|
||||
}
|
||||
for (key, value) in cmd.get_envs() {
|
||||
match value {
|
||||
Some(value) => out.env(key, value),
|
||||
None => out.env_remove(key),
|
||||
};
|
||||
}
|
||||
out.env(SLOT, slot);
|
||||
out
|
||||
}
|
||||
|
||||
/// Prepare under an exclusive lock. Never fall back to a version-specific executable:
|
||||
/// a locked or incomplete slot is an error, rather than another firewall identity.
|
||||
pub(crate) fn prepare(cmd: &Command) -> io::Result<Option<crate::backend::RuntimeGuard>> {
|
||||
prepare_in(cmd, &crate::backend::data_dir().join("run"))
|
||||
}
|
||||
|
||||
fn prepare_in(cmd: &Command, root: &Path) -> io::Result<Option<crate::backend::RuntimeGuard>> {
|
||||
let (Some(source), Some(slot)) = (value(cmd, SOURCE), value(cmd, SLOT)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let slot = Path::new(slot);
|
||||
let valid_key = slot
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.len() == 64 && n.bytes().all(|b| b.is_ascii_hexdigit()));
|
||||
if slot.parent() != Some(root) || !valid_key {
|
||||
return Err(io::ErrorKind::InvalidInput.into());
|
||||
}
|
||||
stage(Path::new(source), slot).map(Some)
|
||||
}
|
||||
|
||||
fn plain_directory(path: &Path) -> io::Result<()> {
|
||||
fs::create_dir_all(path)?;
|
||||
// A replaced runtime slot or game folder must never redirect cleanup to player data.
|
||||
if fs::symlink_metadata(path)?.file_attributes() & 0x400 != 0 {
|
||||
return Err(io::ErrorKind::InvalidInput.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stage(source: &Path, slot: &Path) -> io::Result<crate::backend::RuntimeGuard> {
|
||||
plain_directory(slot)?;
|
||||
let lock = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.open(slot.join("run.lock"))?;
|
||||
lock.try_lock().map_err(io::Error::other)?;
|
||||
// Retain the original exe's protection against deletion/rebuild while playing.
|
||||
let source_file = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.share_mode(1)
|
||||
.open(source.join(crate::backend::CLIENT))?;
|
||||
let game = slot.join("game");
|
||||
plain_directory(&game)?;
|
||||
// Windows refuses this write while an orphaned game still executes the old binary,
|
||||
// even if its launcher (and thus the advisory lock) has already exited.
|
||||
fs::copy(
|
||||
source.join(crate::backend::CLIENT),
|
||||
game.join(crate::backend::CLIENT),
|
||||
)?;
|
||||
for entry in fs::read_dir(&game)? {
|
||||
let entry = entry?;
|
||||
if entry.file_name() == crate::backend::CLIENT {
|
||||
continue;
|
||||
}
|
||||
if entry.file_type()?.is_dir() {
|
||||
fs::remove_dir_all(entry.path())?;
|
||||
} else {
|
||||
fs::remove_file(entry.path())?;
|
||||
}
|
||||
}
|
||||
// Runtime files (DLLs, native-data and native-mods.json) stay beside the exe.
|
||||
// --resources still names the installed version's own resources.
|
||||
for entry in fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
if entry.file_name() == crate::backend::CLIENT || entry.file_name() == ".local" {
|
||||
continue;
|
||||
}
|
||||
let to = game.join(entry.file_name());
|
||||
if entry.file_type()?.is_dir() {
|
||||
crate::backend::copy_dir(&entry.path(), &to)?;
|
||||
} else {
|
||||
fs::copy(entry.path(), to)?;
|
||||
}
|
||||
}
|
||||
fs::write(
|
||||
slot.join("source.json"),
|
||||
serde_json::to_vec(&fs::canonicalize(source)?)?,
|
||||
)?;
|
||||
Ok(crate::backend::RuntimeGuard {
|
||||
_slot: lock,
|
||||
_source: source_file,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn in_use(source: &Path) -> bool {
|
||||
in_use_in(source, &crate::backend::data_dir().join("run"))
|
||||
}
|
||||
|
||||
fn in_use_in(source: &Path, root: &Path) -> bool {
|
||||
let source = fs::canonicalize(source).unwrap_or_else(|_| source.to_path_buf());
|
||||
let slots = match fs::read_dir(root) {
|
||||
Ok(slots) => slots,
|
||||
Err(e) => return e.kind() != io::ErrorKind::NotFound,
|
||||
};
|
||||
slots.filter_map(Result::ok).any(|slot| {
|
||||
let recorded = fs::read(slot.path().join("source.json"))
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice::<std::path::PathBuf>(&bytes).ok());
|
||||
if recorded.as_ref() != Some(&source) {
|
||||
return false;
|
||||
}
|
||||
let Ok(lock) = fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(slot.path().join("run.lock"))
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
if lock.try_lock().is_err() {
|
||||
return true;
|
||||
}
|
||||
// If the launcher exited first, Windows still protects the running image.
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(slot.path().join("game").join(crate::backend::CLIENT))
|
||||
.is_err()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_orphaned_process_keeps_its_runtime_and_source_version_protected() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-orphan-{}", std::process::id()));
|
||||
let source = root.join("version");
|
||||
fs::create_dir_all(&source).unwrap();
|
||||
fs::copy(
|
||||
Path::new(&std::env::var_os("SystemRoot").unwrap()).join("System32/cmd.exe"),
|
||||
source.join(crate::backend::CLIENT),
|
||||
)
|
||||
.unwrap();
|
||||
let mut cmd = Command::new(source.join(crate::backend::CLIENT));
|
||||
cmd.current_dir(&root).args(["/D", "/C", "set /p answer="]);
|
||||
mark(&mut cmd, &source);
|
||||
let mut cmd = redirect_in(cmd, &root.join("run"));
|
||||
let guard = prepare_in(&cmd, &root.join("run")).unwrap().unwrap();
|
||||
crate::process::hide_console(&mut cmd);
|
||||
let mut child = cmd
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
drop(guard); // Simulate a launcher exiting before its game.
|
||||
let running = child.try_wait().unwrap().is_none();
|
||||
let protected = in_use_in(&source, &root.join("run"));
|
||||
let replacement = prepare_in(&cmd, &root.join("run"));
|
||||
let _ = child.kill();
|
||||
child.wait().unwrap();
|
||||
assert!(running && protected);
|
||||
assert!(replacement.is_err());
|
||||
assert!(!in_use_in(&source, &root.join("run")));
|
||||
drop(prepare_in(&cmd, &root.join("run")).unwrap());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_reuse_a_slot_keep_native_files_and_cannot_replace_a_running_game() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-runtime-{}", std::process::id()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(&home).unwrap();
|
||||
let command = |version: &str| {
|
||||
let source = root.join(version);
|
||||
fs::create_dir_all(source.join("native-data/mod")).unwrap();
|
||||
fs::write(source.join(crate::backend::CLIENT), version).unwrap();
|
||||
fs::write(source.join("native-data/mod/table"), version).unwrap();
|
||||
let mut cmd = Command::new(source.join(crate::backend::CLIENT));
|
||||
cmd.current_dir(&home)
|
||||
.env("HORIZON_HOME", &home)
|
||||
.arg("--offscreen");
|
||||
mark(&mut cmd, &source);
|
||||
redirect_in(cmd, &root.join("run"))
|
||||
};
|
||||
let first = command("v1");
|
||||
let lock = prepare_in(&first, &root.join("run")).unwrap().unwrap();
|
||||
assert!(in_use_in(&root.join("v1"), &root.join("run")));
|
||||
assert!(fs::remove_file(root.join("v1").join(crate::backend::CLIENT)).is_err());
|
||||
let game = Path::new(first.get_program()).parent().unwrap();
|
||||
fs::write(game.join("removed.dll"), "old").unwrap();
|
||||
let second = command("v2");
|
||||
assert_eq!(first.get_program(), second.get_program());
|
||||
assert!(prepare_in(&second, &root.join("run")).is_err());
|
||||
assert_eq!(fs::read_to_string(first.get_program()).unwrap(), "v1");
|
||||
drop(lock);
|
||||
assert!(!in_use_in(&root.join("v1"), &root.join("run")));
|
||||
let lock = prepare_in(&second, &root.join("run")).unwrap().unwrap();
|
||||
assert!(!in_use_in(&root.join("v1"), &root.join("run")));
|
||||
assert!(in_use_in(&root.join("v2"), &root.join("run")));
|
||||
assert_eq!(fs::read_to_string(second.get_program()).unwrap(), "v2");
|
||||
assert_eq!(
|
||||
fs::read_to_string(game.join("native-data/mod/table")).unwrap(),
|
||||
"v2"
|
||||
);
|
||||
assert!(!game.join("removed.dll").exists());
|
||||
assert_eq!(second.get_current_dir(), Some(home.as_path()));
|
||||
assert_eq!(second.get_args().collect::<Vec<_>>(), ["--offscreen"]);
|
||||
drop(lock);
|
||||
let unrelated = root.join("unrelated");
|
||||
fs::create_dir_all(&unrelated).unwrap();
|
||||
fs::write(unrelated.join("keep.txt"), "player data").unwrap();
|
||||
let mut injected = command("v3");
|
||||
injected.env(SLOT, &unrelated);
|
||||
assert!(prepare_in(&injected, &root.join("run")).is_err());
|
||||
assert_eq!(
|
||||
fs::read_to_string(unrelated.join("keep.txt")).unwrap(),
|
||||
"player data"
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,12 @@ class IntegrationEnvironmentTests(unittest.TestCase):
|
||||
self.assertEqual(env['HORIZON_TELEMETRY_URL'], 'off')
|
||||
self.assertEqual(env['HORIZON_LAUNCHER_NO_SELF_UPDATE'], '1')
|
||||
self.assertEqual(env['HORIZON_NO_AUDIO'], '1')
|
||||
self.assertEqual(env['HORIZON_TEST_LOOPBACK'], '1')
|
||||
|
||||
def test_real_lan_suites_can_keep_the_network_interfaces(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
env = game_environment({'HORIZON_TEST_LOOPBACK': '0'}, root)
|
||||
self.assertEqual(env['HORIZON_TEST_LOOPBACK'], '0')
|
||||
|
||||
def test_each_fixture_process_gets_fresh_homes_and_cleanup(self):
|
||||
script = ('import json, os; from pathlib import Path; '
|
||||
|
||||
@@ -80,6 +80,9 @@ def game_env(base=None, **extra):
|
||||
env.update({key: str(value) for key, value in extra.items()})
|
||||
env["HORIZON_NO_AUDIO"] = "1"
|
||||
env["HORIZON_LAUNCHER_NO_SELF_UPDATE"] = "1"
|
||||
# Local fixtures use real TCP, but need no inbound access from other machines.
|
||||
# Network/discovery suites can explicitly set HORIZON_TEST_LOOPBACK=0.
|
||||
env.setdefault("HORIZON_TEST_LOOPBACK", "1")
|
||||
# Never a real Discord's Rich Presence; tools/discord_presence_e2e.py points the game at a fake one.
|
||||
if not env.get("HORIZON_DISCORD_IPC_DIR"):
|
||||
env["HORIZON_NO_DISCORD"] = "1"
|
||||
|
||||
@@ -85,6 +85,7 @@ NAME = 'dedicated-server-parity'
|
||||
SEED = 12345
|
||||
PLAYER = 'Tester'
|
||||
PROPERTIES = {
|
||||
'server-ip': '127.0.0.1',
|
||||
'online-mode': 'false',
|
||||
'white-list': 'false',
|
||||
'level-seed': str(SEED),
|
||||
|
||||
Reference in new issue
Block a user