From 2fe2e8cdbc3bb520994ff0944188f2fa657c546a Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 17:49:08 +0100 Subject: v4.0.0: require HELLO MAJOR.MINOR lockstep before auth --- src/version.rs | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/version.rs (limited to 'src/version.rs') diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..c1dcee5 --- /dev/null +++ b/src/version.rs @@ -0,0 +1,117 @@ +//! Version compatibility and the `HELLO` handshake. +//! +//! Client and server must share MAJOR.MINOR; only the patch component may +//! differ. The first line after TLS is `HELLO `, and nothing else is +//! accepted until the server has answered it. + +use std::env; + +/// Version this build reports. The client and the server share it. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Where players download new releases when the server advertises one and the +/// operator has not set `DOWNLOAD_URL`. +pub const DEFAULT_DOWNLOAD_URL: &str = "https://zagan.2137697.xyz/"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Version { + pub major: u64, + pub minor: u64, + pub patch: u64, +} + +impl Version { + /// Parse `MAJOR.MINOR.PATCH`, tolerating a leading `v` and a pre-release or + /// build suffix (`4.0.0-rc1`, `4.0.0+build`). + pub fn parse(raw: &str) -> Option { + let raw = raw.trim(); + let raw = raw.strip_prefix('v').unwrap_or(raw); + + // Drop a pre-release or build suffix, but not an empty one. + let (core, suffix) = match raw.find(['-', '+']) { + Some(index) => (&raw[..index], Some(&raw[index + 1..])), + None => (raw, None), + }; + if suffix == Some("") { + return None; + } + + let mut parts = core.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + let patch = parts.next()?.parse().ok()?; + if parts.next().is_some() { + return None; + } + + Some(Self { + major, + minor, + patch, + }) + } + + pub fn compatible_with(self, other: Self) -> bool { + self.major == other.major && self.minor == other.minor + } +} + +/// True when both versions parse and share MAJOR.MINOR. +pub fn compatible(client: &str, server: &str) -> bool { + match (Version::parse(client), Version::parse(server)) { + (Some(client), Some(server)) => client.compatible_with(server), + _ => false, + } +} + +/// True when `latest` is strictly newer than `current`. Unparseable versions +/// are never "newer", so a broken reply cannot nag the player. +pub fn is_newer(latest: &str, current: &str) -> bool { + match (Version::parse(latest), Version::parse(current)) { + (Some(latest), Some(current)) => latest > current, + _ => false, + } +} + +/// The server line that accepts a handshake. +pub fn hello_line(version: &str, latest: &str, url: &str) -> String { + format!("HELLO {version} latest={latest} url={url}") +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HelloReply { + pub version: String, + pub latest: String, + pub url: String, +} + +/// Parse `HELLO latest= url=`. +pub fn parse_hello(line: &str) -> Option { + let rest = line.trim().strip_prefix("HELLO ")?; + let mut tokens = rest.split_whitespace(); + let version = tokens.next()?.to_string(); + + let mut latest = None; + let mut url = None; + for token in tokens { + if let Some(value) = token.strip_prefix("latest=") { + latest = Some(value.to_string()); + } else if let Some(value) = token.strip_prefix("url=") { + url = Some(value.to_string()); + } + } + + Some(HelloReply { + version, + latest: latest?, + url: url?, + }) +} + +/// Download page advertised by the server. `DOWNLOAD_URL` wins when set. +pub fn download_url() -> String { + env::var("DOWNLOAD_URL") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_DOWNLOAD_URL.to_string()) +} -- cgit v1.2.3