Skip to main content

pinch_points/app/update/
github.rs

1//! Asking GitHub which release is newest: the two addresses, the version
2//! and release read out of what comes back, and the made-up release the
3//! demo switch offers.
4
5use bevy::prelude::*;
6use std::time::Duration;
7
8/// The two places GitHub answers "which release is newest": the REST
9/// API, which says so in JSON with the release notes attached, and the
10/// releases page, which redirects to the newest one and says nothing else.
11///
12/// The API is asked first and is the one that gives the page its notes.
13/// It is also rationed: sixty answers an hour to everyone behind one
14/// public address, and a hall of players launching the game behind one
15/// router is exactly the busy afternoon that runs it dry. When it is dry
16/// the page is asked instead, which is not rationed that way, so the
17/// offer still stands, only without the notes.
18///
19/// Both are built from the manifest's repository so a fork asks about
20/// itself.
21pub struct Where {
22    pub api: String,
23    pub page: String,
24}
25
26pub(super) fn latest_release_urls() -> Option<Where> {
27    let repo = env!("CARGO_PKG_REPOSITORY").strip_prefix("https://github.com/")?;
28    Some(Where {
29        api: format!("https://api.github.com/repos/{repo}/releases/latest"),
30        page: format!("https://github.com/{repo}/releases/latest"),
31    })
32}
33
34/// How long the check may take, connect to last byte, before the thread
35/// gives up. Short: on a slow line the answer is worth less than the
36/// bother of a page appearing after the player has settled in, and
37/// nothing on the menu waits on it either way.
38const TIMEOUT: Duration = Duration::from_secs(3);
39
40/// A release version. Semver underneath, so a pre-release sorts below
41/// the release it precedes: a `0.2.0-rc.1` build is told when `0.2.0` is
42/// out, and an `rc` tagged latest is offered as the `rc` it is.
43#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
44pub struct Version(semver::Version);
45
46impl Version {
47    /// `1.2.3`, `v1.2.3`, `v1.2.3-rc.1`. Anything else is not a version.
48    pub fn parse(tag: &str) -> Option<Version> {
49        let tag = tag.trim();
50        let tag = tag.strip_prefix(['v', 'V']).unwrap_or(tag);
51        semver::Version::parse(tag).ok().map(Version)
52    }
53
54    /// The version this binary was built as.
55    pub fn current() -> Version {
56        // The manifest's version is a valid semver or cargo would not
57        // have built this; a test says so as well.
58        Version::parse(env!("CARGO_PKG_VERSION"))
59            .unwrap_or_else(|| Version(semver::Version::new(0, 0, 0)))
60    }
61}
62
63impl std::fmt::Display for Version {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        write!(f, "v{}", self.0)
66    }
67}
68
69/// What GitHub said the newest release is.
70#[derive(Clone, PartialEq, Eq, Debug)]
71pub struct Release {
72    pub version: Version,
73    /// The tag as GitHub spells it (`v1.2.0`), for the log and the page.
74    pub tag: String,
75    /// The release page, for the browser.
76    pub url: String,
77    /// The release notes, as written: markdown, usually.
78    pub notes: String,
79}
80
81/// What a release page's address must look like before it is handed to
82/// a browser (or, on Windows, to `cmd`): on GitHub, over https, and made
83/// of nothing a shell has opinions about. No percent, in particular:
84/// `cmd` expands `%NAME%`, and a release page of ours has never needed
85/// an escape. The reply is GitHub's own over TLS, so this is belt to
86/// those braces, but the address is the one thing here that leaves the
87/// process.
88fn is_release_page(url: &str) -> bool {
89    url.starts_with("https://github.com/")
90        && url.chars().all(|c| {
91            c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_' | '~' | '/' | ':' | '+')
92        })
93}
94
95/// Read a release out of the GitHub API's `releases/latest` reply. `None`
96/// for anything that is not one, a tag that is not a version included.
97pub fn parse_release(json: &str) -> Option<Release> {
98    let value: serde_json::Value = serde_json::from_str(json).ok()?;
99    let tag = value.get("tag_name")?.as_str()?.trim();
100    let version = Version::parse(tag)?;
101    let url = value.get("html_url")?.as_str()?.trim();
102    if !is_release_page(url) {
103        return None;
104    }
105    let notes = value
106        .get("body")
107        .and_then(serde_json::Value::as_str)
108        .unwrap_or("")
109        .to_string();
110    Some(Release {
111        version,
112        tag: tag.to_string(),
113        url: url.to_string(),
114        notes,
115    })
116}
117
118/// Ask GitHub. Blocking; runs on the check thread. `None` for any kind of
119/// no: offline, too slow, no release yet, or an answer that is not a
120/// release. Each is logged at a level nobody is shown, and none is the
121/// player's problem.
122pub fn fetch_latest(urls: &Where) -> Option<Release> {
123    let agent = ureq::Agent::config_builder()
124        .timeout_global(Some(TIMEOUT))
125        .user_agent(concat!("pinch-points/", env!("CARGO_PKG_VERSION")))
126        // The page's answer *is* its redirect; following it would fetch a
127        // release page's worth of HTML to learn what the header said.
128        .max_redirects(0)
129        .build()
130        .new_agent();
131    let release = match ask_api(&agent, &urls.api) {
132        Ok(release) => release,
133        // 403 is how the API says its hourly ration is spent (429 is what
134        // it is documented to say). Not fatal: the page knows the tag.
135        Err(ureq::Error::StatusCode(403 | 429)) => {
136            info!("update check: the API is rationed out; asking the releases page");
137            ask_page(&agent, &urls.page)
138        }
139        Err(e) => {
140            info!("update check: {e}");
141            None
142        }
143    };
144    match &release {
145        Some(release) => info!("update check: newest release is {}", release.tag),
146        None => info!("update check: no release to offer"),
147    }
148    release
149}
150
151/// The API's answer, notes and all. `Ok(None)` for a reply that is not a
152/// release; the error for a reply that is not a 2xx.
153fn ask_api(agent: &ureq::Agent, url: &str) -> Result<Option<Release>, ureq::Error> {
154    let body = agent
155        .get(url)
156        .header("Accept", "application/vnd.github+json")
157        .call()?
158        .body_mut()
159        .read_to_string()?;
160    Ok(parse_release(&body))
161}
162
163/// The page's answer: the tag in the redirect, and no notes.
164fn ask_page(agent: &ureq::Agent, url: &str) -> Option<Release> {
165    let response = match agent.get(url).call() {
166        Ok(response) => response,
167        Err(e) => {
168            info!("update check: {e}");
169            return None;
170        }
171    };
172    if !response.status().is_redirection() {
173        info!("update check: the releases page did not redirect");
174        return None;
175    }
176    let location = response.headers().get("location")?.to_str().ok()?;
177    release_from_redirect(location)
178}
179
180/// A release read off where the releases page sends the browser:
181/// `.../releases/tag/v1.2.0`. Notes are not to be had this way.
182pub fn release_from_redirect(location: &str) -> Option<Release> {
183    let location = location.trim();
184    let (_, tag) = location.rsplit_once("/releases/tag/")?;
185    if !is_release_page(location) || tag.is_empty() || tag.contains('/') {
186        return None;
187    }
188    let version = Version::parse(tag)?;
189    Some(Release {
190        version,
191        tag: tag.to_string(),
192        url: location.to_string(),
193        notes: String::new(),
194    })
195}
196
197/// The made-up release `PINCH_UPDATE_DEMO` offers.
198pub(super) fn demo_release() -> Release {
199    let mut version = Version::current();
200    version.0.minor += 1;
201    version.0.patch = 0;
202    version.0.pre = semver::Prerelease::EMPTY;
203    Release {
204        tag: version.to_string(),
205        url: format!("{}/releases", env!("CARGO_PKG_REPOSITORY")),
206        notes: "## What's new\n\n\
207                - A **new tide event**: the undertow, which drags every loose crab one tile seaward.\n\
208                - Two more Beach Day challenges.\n\
209                - The lobby names the beach that went off the air instead of just dropping it.\n\n\
210                ## Fixes\n\n\
211                - Pairs mode no longer overflows the settings row in German.\n\
212                - See [the changelog](https://example.invalid/changelog) for the rest.\n"
213            .to_string(),
214        version,
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::super::UpdateCheck;
221    use super::super::notes::notes_lines;
222    use super::*;
223
224    #[test]
225    fn versions_parse_and_order() {
226        let v = |s| Version::parse(s);
227        assert_eq!(v("v1.2.3"), Some(Version(semver::Version::new(1, 2, 3))));
228        assert_eq!(v("1.2.3"), v("v1.2.3"));
229        assert_eq!(v(" V1.2.3 "), v("v1.2.3"));
230        // A pre-release is a version, below the release it precedes.
231        assert!(v("v1.2.3-rc.1").is_some());
232        assert!(v("v1.2.3-rc.1") < v("v1.2.3"));
233        assert!(v("v1.2.3-rc.1") > v("v1.2.2"));
234        assert!(v("v1.2.3+build.7").is_some());
235        assert_eq!(v("v1.2"), None);
236        assert_eq!(v("v1.2.3.4"), None);
237        assert_eq!(v("latest"), None);
238        assert_eq!(v(""), None);
239        assert_eq!(v("v1.x.3"), None);
240        assert!(v("v1.10.0") > v("v1.9.9"));
241        assert!(v("v2.0.0") > v("v1.99.99"));
242        assert!(v("v0.1.1") > v("v0.1.0"));
243        assert_eq!(v("v0.1.0").unwrap().to_string(), "v0.1.0");
244        assert_eq!(v("0.2.0-rc.1").unwrap().to_string(), "v0.2.0-rc.1");
245    }
246
247    /// The manifest's version parses, or the check compares against
248    /// 0.0.0 and offers every release ever made.
249    #[test]
250    fn this_build_has_a_version() {
251        assert_eq!(
252            Some(Version::current()),
253            Version::parse(env!("CARGO_PKG_VERSION"))
254        );
255        assert!(Version::current() > Version::parse("0.0.0").unwrap());
256    }
257
258    /// The addresses come from the manifest, so a fork asks about itself
259    /// rather than about this repository.
260    #[test]
261    fn the_addresses_name_the_repository() {
262        let urls = latest_release_urls().expect("a GitHub repository");
263        assert_eq!(
264            urls.api,
265            "https://api.github.com/repos/agourlay/pinch-points/releases/latest"
266        );
267        assert_eq!(
268            urls.page,
269            "https://github.com/agourlay/pinch-points/releases/latest"
270        );
271    }
272
273    /// The rationed-out fallback: the redirect names the tag, and only
274    /// a redirect to a release tag counts.
275    #[test]
276    fn a_redirect_to_a_tag_is_a_release_without_notes() {
277        let release =
278            release_from_redirect("https://github.com/agourlay/pinch-points/releases/tag/v0.3.1")
279                .expect("a release");
280        assert_eq!(release.tag, "v0.3.1");
281        assert_eq!(release.version, Version::parse("0.3.1").unwrap());
282        assert_eq!(release.notes, "");
283        assert!(release.url.ends_with("/releases/tag/v0.3.1"));
284        assert_eq!(
285            release_from_redirect("https://github.com/agourlay/pinch-points/releases"),
286            None
287        );
288        assert_eq!(
289            release_from_redirect("https://github.com/agourlay/pinch-points/releases/tag/"),
290            None
291        );
292        assert_eq!(
293            release_from_redirect("https://github.com/x/y/releases/tag/nightly"),
294            None
295        );
296        assert_eq!(
297            release_from_redirect("https://evil.example/x/y/releases/tag/v1.0.0"),
298            None
299        );
300        assert_eq!(
301            release_from_redirect("http://github.com/x/y/releases/tag/v1.0.0"),
302            None
303        );
304    }
305
306    /// The shape of GitHub's `releases/latest` reply, cut down to the
307    /// fields read. Everything else in it is ignored, and a body that is
308    /// missing or null is empty notes rather than no release.
309    #[test]
310    fn a_github_reply_is_read_as_a_release() {
311        let json = r#"{
312            "url": "https://api.github.com/repos/agourlay/pinch-points/releases/1",
313            "html_url": "https://github.com/agourlay/pinch-points/releases/tag/v0.2.0",
314            "tag_name": "v0.2.0",
315            "name": "Pinch Points 0.2.0",
316            "draft": false,
317            "prerelease": false,
318            "body": "New tide\r\n\r\n- Undertow\r\n",
319            "assets": []
320        }"#;
321        let release = parse_release(json).expect("a release");
322        assert_eq!(release.tag, "v0.2.0");
323        assert_eq!(release.version, Version::parse("0.2.0").unwrap());
324        assert_eq!(
325            release.url,
326            "https://github.com/agourlay/pinch-points/releases/tag/v0.2.0"
327        );
328        assert_eq!(release.notes, "New tide\r\n\r\n- Undertow\r\n");
329
330        let no_body = r#"{"tag_name": "v0.2.0", "html_url": "https://github.com/x/y/releases/tag/v0.2.0", "body": null}"#;
331        assert_eq!(parse_release(no_body).unwrap().notes, "");
332    }
333
334    /// What is not a release: GitHub's 404 body, a tag that is not a
335    /// version, a page that is not https, and nonsense.
336    /// The one string that leaves the process goes to a browser, or on
337    /// Windows to `cmd`, which expands `%NAME%`. A release page of ours
338    /// has never needed an escape, so a percent is refused outright.
339    #[test]
340    fn a_release_page_carries_no_percent() {
341        assert!(is_release_page(
342            "https://github.com/agourlay/pinch-points/releases/tag/v1.2.3"
343        ));
344        assert!(!is_release_page(
345            "https://github.com/agourlay/pinch-points/releases/tag/%41"
346        ));
347        assert!(!is_release_page("http://github.com/x/y"), "https only");
348        assert!(!is_release_page("https://github.com/x/y?a=b"), "no query");
349    }
350
351    #[test]
352    fn what_is_not_a_release() {
353        assert_eq!(
354            parse_release(r#"{"message": "Not Found", "status": "404"}"#),
355            None
356        );
357        assert_eq!(
358            parse_release(r#"{"tag_name": "nightly", "html_url": "https://github.com/x/y"}"#),
359            None
360        );
361        assert_eq!(
362            parse_release(r#"{"tag_name": "v1.0.0", "html_url": "http://github.com/x/y"}"#),
363            None
364        );
365        // Off GitHub, or carrying something a shell would read: not ours.
366        assert_eq!(
367            parse_release(r#"{"tag_name": "v1.0.0", "html_url": "https://example.com/x"}"#),
368            None
369        );
370        assert_eq!(
371            parse_release(
372                r#"{"tag_name": "v1.0.0", "html_url": "https://github.com/x/y/releases/tag/v1.0.0 & calc"}"#
373            ),
374            None
375        );
376        assert_eq!(
377            parse_release(r#"{"tag_name": "v1.0.0", "html_url": "javascript:alert(1)"}"#),
378            None
379        );
380        assert_eq!(parse_release("<html>rate limited</html>"), None);
381        assert_eq!(parse_release(""), None);
382    }
383
384    /// The demo release is a real one as far as the page is concerned:
385    /// newer than this build, and its notes exercise the flattening.
386    #[test]
387    fn the_demo_release_is_offered() {
388        let demo = demo_release();
389        assert!(UpdateCheck::worth_offering(&Version::current(), &demo));
390        assert!(demo.url.starts_with("https://github.com/"));
391        assert!(notes_lines(&demo.notes, 12).len() > 4);
392    }
393}