pinch-points 0.8.0

A fast, kid-friendly crab-routing game: route streams of crabs into your sandcastle before time runs out
Documentation
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
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! Asking GitHub which release is newest: the two addresses, the version
//! and release read out of what comes back, and the made-up release the
//! demo switch offers.

use bevy::prelude::*;
use std::time::Duration;

/// The two places GitHub answers "which release is newest": the REST
/// API, which says so in JSON with the release notes attached, and the
/// releases page, which redirects to the newest one and says nothing else.
///
/// The API is asked first and is the one that gives the page its notes. It
/// is also rationed, sixty answers an hour to everyone behind one public
/// address, which a hall of players launching the game behind one router
/// runs dry. The page is asked instead then, so the offer still stands,
/// only without the notes.
///
/// Both are built from the manifest's repository so a fork asks about
/// itself.
pub struct Where {
    pub api: String,
    pub page: String,
}

pub(super) fn latest_release_urls() -> Option<Where> {
    let repo = env!("CARGO_PKG_REPOSITORY").strip_prefix("https://github.com/")?;
    Some(Where {
        api: format!("https://api.github.com/repos/{repo}/releases/latest"),
        page: format!("https://github.com/{repo}/releases/latest"),
    })
}

/// How long the check may take, connect to last byte, before the thread
/// gives up. Short: on a slow line the answer is worth less than the
/// bother of a page appearing after the player has settled in, and
/// nothing on the menu waits on it either way.
const TIMEOUT: Duration = Duration::from_secs(3);

/// A release version. Semver underneath, so a pre-release sorts below
/// the release it precedes: a `0.2.0-rc.1` build is told when `0.2.0` is
/// out, and an `rc` tagged latest is offered as the `rc` it is.
///
/// Compared by semver *precedence*, which ignores build metadata, and not
/// by `semver::Version`'s own order, which sorts it: under that, a release
/// tagged `v1.2.3+linux` was newer than the `1.2.3` this build is, and the
/// page offered the player the version they already had.
#[derive(Clone, Debug)]
pub struct Version(semver::Version);

impl PartialEq for Version {
    fn eq(&self, other: &Self) -> bool {
        self.cmp(other).is_eq()
    }
}

impl Eq for Version {}

impl PartialOrd for Version {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for Version {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.0.cmp_precedence(&other.0)
    }
}

impl Version {
    /// `1.2.3`, `v1.2.3`, `v1.2.3-rc.1`. Anything else is not a version.
    pub fn parse(tag: &str) -> Option<Version> {
        let tag = tag.trim();
        let tag = tag.strip_prefix(['v', 'V']).unwrap_or(tag);
        semver::Version::parse(tag).ok().map(Version)
    }

    /// The version this binary was built as.
    pub fn current() -> Version {
        // The manifest's version is a valid semver or cargo would not
        // have built this; a test says so as well.
        Version::parse(env!("CARGO_PKG_VERSION"))
            .unwrap_or_else(|| Version(semver::Version::new(0, 0, 0)))
    }
}

impl std::fmt::Display for Version {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "v{}", self.0)
    }
}

/// What GitHub said the newest release is.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Release {
    pub version: Version,
    /// The tag as GitHub spells it (`v1.2.0`), for the log and the page.
    pub tag: String,
    /// The release page, for the browser.
    pub url: String,
    /// The release notes, as written: markdown, usually.
    pub notes: String,
}

/// What a release page's address must look like before it is handed to a
/// browser (or, on Windows, to `cmd`): on GitHub, over https, and made of
/// nothing a shell has opinions about. No percent in particular, since
/// `cmd` expands `%NAME%`. The reply is GitHub's own over TLS, but the
/// address is the one thing here that leaves the process.
fn is_release_page(url: &str) -> bool {
    url.starts_with("https://github.com/")
        && url.chars().all(|c| {
            c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_' | '~' | '/' | ':' | '+')
        })
}

/// Read a release out of the GitHub API's `releases/latest` reply. `None`
/// for anything that is not one, a tag that is not a version included.
pub fn parse_release(json: &str) -> Option<Release> {
    let value: serde_json::Value = serde_json::from_str(json).ok()?;
    let tag = value.get("tag_name")?.as_str()?.trim();
    let version = Version::parse(tag)?;
    let url = value.get("html_url")?.as_str()?.trim();
    if !is_release_page(url) {
        return None;
    }
    let notes = value
        .get("body")
        .and_then(serde_json::Value::as_str)
        .unwrap_or("")
        .to_string();
    Some(Release {
        version,
        tag: tag.to_string(),
        url: url.to_string(),
        notes,
    })
}

/// Ask GitHub. Blocking; runs on the check thread. `None` for any kind of
/// no: offline, too slow, no release yet, or an answer that is not a
/// release. Each is logged at a level nobody is shown, and none is the
/// player's problem.
pub fn fetch_latest(urls: &Where) -> Option<Release> {
    let agent = ureq::Agent::config_builder()
        .timeout_global(Some(TIMEOUT))
        .user_agent(concat!("pinch-points/", env!("CARGO_PKG_VERSION")))
        // The page's answer *is* its redirect; following it would fetch a
        // release page's worth of HTML to learn what the header said.
        .max_redirects(0)
        .build()
        .new_agent();
    let release = match ask_api(&agent, &urls.api) {
        Ok(release) => release,
        // 403 is how the API says its hourly ration is spent (429 is what
        // it is documented to say). Not fatal: the page knows the tag.
        Err(ureq::Error::StatusCode(403 | 429)) => {
            info!("update check: the API is rationed out; asking the releases page");
            ask_page(&agent, &urls.page)
        }
        Err(e) => {
            info!("update check: {e}");
            None
        }
    };
    match &release {
        Some(release) => info!("update check: newest release is {}", release.tag),
        None => info!("update check: no release to offer"),
    }
    release
}

/// The API's answer, notes and all. `Ok(None)` for a reply that is not a
/// release; the error for a reply that is not a 2xx.
fn ask_api(agent: &ureq::Agent, url: &str) -> Result<Option<Release>, ureq::Error> {
    let body = agent
        .get(url)
        .header("Accept", "application/vnd.github+json")
        .call()?
        .body_mut()
        .read_to_string()?;
    Ok(parse_release(&body))
}

/// The page's answer: the tag in the redirect, and no notes.
fn ask_page(agent: &ureq::Agent, url: &str) -> Option<Release> {
    let response = match agent.get(url).call() {
        Ok(response) => response,
        Err(e) => {
            info!("update check: {e}");
            return None;
        }
    };
    if !response.status().is_redirection() {
        info!("update check: the releases page did not redirect");
        return None;
    }
    let location = response.headers().get("location")?.to_str().ok()?;
    release_from_redirect(location)
}

/// A release read off where the releases page sends the browser:
/// `.../releases/tag/v1.2.0`. Notes are not to be had this way.
pub fn release_from_redirect(location: &str) -> Option<Release> {
    let location = location.trim();
    let (_, tag) = location.rsplit_once("/releases/tag/")?;
    if !is_release_page(location) || tag.is_empty() || tag.contains('/') {
        return None;
    }
    let version = Version::parse(tag)?;
    Some(Release {
        version,
        tag: tag.to_string(),
        url: location.to_string(),
        notes: String::new(),
    })
}

/// The made-up release `PINCH_UPDATE_DEMO` offers.
pub(super) fn demo_release() -> Release {
    let mut version = Version::current();
    version.0.minor += 1;
    version.0.patch = 0;
    version.0.pre = semver::Prerelease::EMPTY;
    Release {
        tag: version.to_string(),
        url: format!("{}/releases", env!("CARGO_PKG_REPOSITORY")),
        notes: "## What's new\n\n\
                - A **new tide event**: the undertow, which drags every loose crab one tile seaward.\n\
                - Two more Beach Day challenges.\n\
                - The lobby names the beach that went off the air instead of just dropping it.\n\n\
                ## Fixes\n\n\
                - Pairs mode no longer overflows the settings row in German.\n\
                - See [the changelog](https://example.invalid/changelog) for the rest.\n"
            .to_string(),
        version,
    }
}

#[cfg(test)]
mod tests {
    use super::super::UpdateCheck;
    use super::super::notes::notes_lines;
    use super::*;

    #[test]
    fn versions_parse_and_order() {
        let v = |s| Version::parse(s);
        assert_eq!(v("v1.2.3"), Some(Version(semver::Version::new(1, 2, 3))));
        assert_eq!(v("1.2.3"), v("v1.2.3"));
        assert_eq!(v(" V1.2.3 "), v("v1.2.3"));
        // A pre-release is a version, below the release it precedes.
        assert!(v("v1.2.3-rc.1").is_some());
        assert!(v("v1.2.3-rc.1") < v("v1.2.3"));
        assert!(v("v1.2.3-rc.1") > v("v1.2.2"));
        assert!(v("v1.2.3+build.7").is_some());
        // Build metadata has no precedence: the same version either way.
        assert_eq!(v("v1.2.3+build.7"), v("v1.2.3"));
        assert!(v("v1.2.3+linux") <= v("1.2.3"), "not newer than itself");
        assert_eq!(v("v1.2"), None);
        assert_eq!(v("v1.2.3.4"), None);
        assert_eq!(v("latest"), None);
        assert_eq!(v(""), None);
        assert_eq!(v("v1.x.3"), None);
        assert!(v("v1.10.0") > v("v1.9.9"));
        assert!(v("v2.0.0") > v("v1.99.99"));
        assert!(v("v0.1.1") > v("v0.1.0"));
        assert_eq!(v("v0.1.0").unwrap().to_string(), "v0.1.0");
        assert_eq!(v("0.2.0-rc.1").unwrap().to_string(), "v0.2.0-rc.1");
    }

    /// The manifest's version parses, or the check compares against
    /// 0.0.0 and offers every release ever made.
    #[test]
    fn this_build_has_a_version() {
        assert_eq!(
            Some(Version::current()),
            Version::parse(env!("CARGO_PKG_VERSION"))
        );
        assert!(Version::current() > Version::parse("0.0.0").unwrap());
    }

    /// The addresses come from the manifest, so a fork asks about itself
    /// rather than about this repository.
    #[test]
    fn the_addresses_name_the_repository() {
        let urls = latest_release_urls().expect("a GitHub repository");
        assert_eq!(
            urls.api,
            "https://api.github.com/repos/agourlay/pinch-points/releases/latest"
        );
        assert_eq!(
            urls.page,
            "https://github.com/agourlay/pinch-points/releases/latest"
        );
    }

    /// The rationed-out fallback: the redirect names the tag, and only
    /// a redirect to a release tag counts.
    #[test]
    fn a_redirect_to_a_tag_is_a_release_without_notes() {
        let release =
            release_from_redirect("https://github.com/agourlay/pinch-points/releases/tag/v0.3.1")
                .expect("a release");
        assert_eq!(release.tag, "v0.3.1");
        assert_eq!(release.version, Version::parse("0.3.1").unwrap());
        assert_eq!(release.notes, "");
        assert!(release.url.ends_with("/releases/tag/v0.3.1"));
        assert_eq!(
            release_from_redirect("https://github.com/agourlay/pinch-points/releases"),
            None
        );
        assert_eq!(
            release_from_redirect("https://github.com/agourlay/pinch-points/releases/tag/"),
            None
        );
        assert_eq!(
            release_from_redirect("https://github.com/x/y/releases/tag/nightly"),
            None
        );
        assert_eq!(
            release_from_redirect("https://evil.example/x/y/releases/tag/v1.0.0"),
            None
        );
        assert_eq!(
            release_from_redirect("http://github.com/x/y/releases/tag/v1.0.0"),
            None
        );
    }

    /// The shape of GitHub's `releases/latest` reply, cut down to the
    /// fields read. Everything else in it is ignored, and a body that is
    /// missing or null is empty notes rather than no release.
    #[test]
    fn a_github_reply_is_read_as_a_release() {
        let json = r#"{
            "url": "https://api.github.com/repos/agourlay/pinch-points/releases/1",
            "html_url": "https://github.com/agourlay/pinch-points/releases/tag/v0.2.0",
            "tag_name": "v0.2.0",
            "name": "Pinch Points 0.2.0",
            "draft": false,
            "prerelease": false,
            "body": "New tide\r\n\r\n- Undertow\r\n",
            "assets": []
        }"#;
        let release = parse_release(json).expect("a release");
        assert_eq!(release.tag, "v0.2.0");
        assert_eq!(release.version, Version::parse("0.2.0").unwrap());
        assert_eq!(
            release.url,
            "https://github.com/agourlay/pinch-points/releases/tag/v0.2.0"
        );
        assert_eq!(release.notes, "New tide\r\n\r\n- Undertow\r\n");

        let no_body = r#"{"tag_name": "v0.2.0", "html_url": "https://github.com/x/y/releases/tag/v0.2.0", "body": null}"#;
        assert_eq!(parse_release(no_body).unwrap().notes, "");
    }

    /// The one string that leaves the process goes to a browser, or on
    /// Windows to `cmd`, which expands `%NAME%`. A release page of ours has
    /// never needed an escape, so a percent is refused outright.
    #[test]
    fn a_release_page_carries_no_percent() {
        assert!(is_release_page(
            "https://github.com/agourlay/pinch-points/releases/tag/v1.2.3"
        ));
        assert!(!is_release_page(
            "https://github.com/agourlay/pinch-points/releases/tag/%41"
        ));
        assert!(!is_release_page("http://github.com/x/y"), "https only");
        assert!(!is_release_page("https://github.com/x/y?a=b"), "no query");
    }

    /// What is not a release: GitHub's 404 body, a tag that is not a
    /// version, a page that is not https, and nonsense.
    #[test]
    fn what_is_not_a_release() {
        assert_eq!(
            parse_release(r#"{"message": "Not Found", "status": "404"}"#),
            None
        );
        assert_eq!(
            parse_release(r#"{"tag_name": "nightly", "html_url": "https://github.com/x/y"}"#),
            None
        );
        assert_eq!(
            parse_release(r#"{"tag_name": "v1.0.0", "html_url": "http://github.com/x/y"}"#),
            None
        );
        // Off GitHub, or carrying something a shell would read: not ours.
        assert_eq!(
            parse_release(r#"{"tag_name": "v1.0.0", "html_url": "https://example.com/x"}"#),
            None
        );
        assert_eq!(
            parse_release(
                r#"{"tag_name": "v1.0.0", "html_url": "https://github.com/x/y/releases/tag/v1.0.0 & calc"}"#
            ),
            None
        );
        assert_eq!(
            parse_release(r#"{"tag_name": "v1.0.0", "html_url": "javascript:alert(1)"}"#),
            None
        );
        assert_eq!(parse_release("<html>rate limited</html>"), None);
        assert_eq!(parse_release(""), None);
    }

    /// The demo release is a real one as far as the page is concerned:
    /// newer than this build, and its notes exercise the flattening.
    #[test]
    fn the_demo_release_is_offered() {
        let demo = demo_release();
        assert!(UpdateCheck::worth_offering(&Version::current(), &demo));
        assert!(demo.url.starts_with("https://github.com/"));
        assert!(notes_lines(&demo.notes, 12).len() > 4);
    }
}