summaryrefslogtreecommitdiffstats
path: root/src/version.rs
blob: adc2a8d33cc76c9d0f88190e2e806f6cae541dbf (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
//! 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 <version>`, and nothing else is
//! accepted until the server has answered it.

use std::env;
use std::time::{SystemTime, UNIX_EPOCH};

/// 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<Self> {
        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. `time` is Unix milliseconds.
pub fn hello_line(version: &str, latest: &str, url: &str, time: u64) -> String {
    format!("HELLO {version} latest={latest} url={url} time={time}")
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelloReply {
    pub version: String,
    pub latest: String,
    pub url: String,
    /// Server clock at the moment the reply was built, Unix milliseconds.
    pub time: u64,
}

/// Parse `HELLO <version> latest=<version> url=<url> time=<unix_ms>`.
pub fn parse_hello(line: &str) -> Option<HelloReply> {
    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;
    let mut time = 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());
        } else if let Some(value) = token.strip_prefix("time=") {
            time = value.parse().ok();
        }
    }

    Some(HelloReply {
        version,
        latest: latest?,
        url: url?,
        time: time?,
    })
}

/// The reply to `PING`: `PONG <unix_ms>`.
pub fn pong_line(time: u64) -> String {
    format!("PONG {time}")
}

/// Parse a `PONG` reply's server time.
pub fn parse_pong(line: &str) -> Option<u64> {
    line.trim().strip_prefix("PONG ")?.trim().parse().ok()
}

/// Current Unix time in milliseconds. Used for handshake timestamps and crash
/// anchors; a system clock before the epoch reads as zero.
pub fn unix_time_ms() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
        .unwrap_or(0)
}

/// 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())
}