Skip to main content

guise/update/
release.rs

1//! The release check: fetch the latest published release, compare it against
2//! the running version, and decide whether it is something this machine can
3//! actually install.
4//!
5//! That last part is load-bearing. A GitHub release is created and published
6//! *before* CI finishes building and uploading its assets, so for however long
7//! a notarization run takes, `releases/latest` reports a version whose only
8//! uploaded files may be for another platform. Offering that release produces an
9//! Update button whose sole possible outcome is "no asset for this platform" —
10//! which is exactly what a prompt should never do. [`Release::ready_for`] is
11//! what holds it back until the artifact exists.
12
13use super::install::InstallKind;
14use super::json;
15use super::{fetch, semver};
16
17/// Where to look for releases.
18///
19/// Both forms expect the shape of GitHub's release API (`tag_name`, `html_url`,
20/// and an `assets` array of `name` / `browser_download_url` / `size`), because
21/// that is the format the overwhelming majority of desktop apps already publish.
22/// Point [`UpdateSource::url`] at your own endpoint to serve the same JSON from
23/// somewhere else.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum UpdateSource {
26    /// GitHub's `releases/latest` for an `owner/repo` slug.
27    GitHub(String),
28    /// A URL answering with the same JSON as GitHub's release API.
29    Url(String),
30}
31
32impl UpdateSource {
33    /// Releases published to a GitHub repo, given as `owner/repo`.
34    pub fn github(repo: impl Into<String>) -> Self {
35        UpdateSource::GitHub(repo.into())
36    }
37
38    /// A custom endpoint serving GitHub-shaped release JSON.
39    pub fn url(url: impl Into<String>) -> Self {
40        UpdateSource::Url(url.into())
41    }
42
43    /// The URL to fetch the latest release from.
44    pub fn endpoint(&self) -> String {
45        match self {
46            UpdateSource::GitHub(repo) => {
47                format!("https://api.github.com/repos/{repo}/releases/latest")
48            }
49            UpdateSource::Url(url) => url.clone(),
50        }
51    }
52}
53
54/// One uploaded release asset.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct ReleaseAsset {
57    /// The uploaded file name, e.g. `Acme-1.27.8-aarch64.AppImage`.
58    pub name: String,
59    /// Direct download URL.
60    pub url: String,
61    /// Byte size as the feed reports it. Drives the download progress bar and
62    /// the truncation check after the download; 0 when the field is absent.
63    pub size: u64,
64}
65
66/// A published release.
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct Release {
69    /// Semver without the leading `v` (e.g. `1.25.0`).
70    pub version: String,
71    /// The release page URL — where "Release Notes" and the download fallback go.
72    pub url: String,
73    /// Every uploaded asset.
74    pub assets: Vec<ReleaseAsset>,
75}
76
77/// The outcome of a check. [`UpdateCheck::Pending`] exists so a manual "Check
78/// for Updates…" can say "still building" instead of the flat lie that you are
79/// up to date.
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub enum UpdateCheck {
82    /// Nothing newer is published.
83    UpToDate,
84    /// A newer release exists, but it hasn't uploaded anything this machine can
85    /// use yet. Carries the version so the UI can name it.
86    Pending(String),
87    /// A newer release with the asset this install needs.
88    Ready(Release),
89}
90
91/// Whether `name` is built for `arch`. Release artifacts spell architectures
92/// inconsistently by design: `cargo-deb` writes Debian names (`arm64`, `amd64`)
93/// while tarballs and AppImages carry the Rust/uname spelling.
94fn matches_arch(name: &str, arch: &str) -> bool {
95    let aliases: &[&str] = match arch {
96        "aarch64" => &["aarch64", "arm64"],
97        "x86_64" => &["x86_64", "amd64"],
98        other => &[other],
99    };
100    aliases.iter().any(|alias| contains_token(name, alias))
101}
102
103/// Whether `name` contains `token` as a whole architecture field rather than as
104/// a bare substring. [`std::env::consts::ARCH`] is `"x86"` on 32-bit x86 and
105/// `"arm"` on 32-bit ARM, both substrings of the 64-bit asset names — so a loose
106/// test would have an i686 install match the `x86_64` image and rename it over
107/// itself, the very clobber arch matching exists to stop.
108///
109/// A trailing `_` does *not* end the token, because `_` continues one
110/// (`x86_64`); a leading one does, because that is how `cargo-deb` delimits
111/// fields (`acme_1.27.8_arm64.deb`).
112fn contains_token(name: &str, token: &str) -> bool {
113    let bytes = name.as_bytes();
114    name.match_indices(token).any(|(i, _)| {
115        let before = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
116        let end = i + token.len();
117        let after =
118            end == bytes.len() || (!bytes[end].is_ascii_alphanumeric() && bytes[end] != b'_');
119        before && after
120    })
121}
122
123/// The first asset whose name contains `needle`, optionally restricted to assets
124/// built for `arch`.
125fn pick<'a>(
126    assets: &'a [ReleaseAsset],
127    needle: &str,
128    arch: Option<&str>,
129) -> Option<&'a ReleaseAsset> {
130    assets
131        .iter()
132        .find(|a| a.name.contains(needle) && arch.is_none_or(|x| matches_arch(&a.name, x)))
133}
134
135impl Release {
136    /// The download this install would fetch, if the release has published it.
137    /// `None` for [`InstallKind::Unknown`], which has no in-place path at all.
138    pub fn asset_for(&self, kind: &InstallKind) -> Option<&ReleaseAsset> {
139        match kind {
140            InstallKind::MacApp(_) => pick(&self.assets, ".dmg", None),
141            InstallKind::AppImage(_) => {
142                pick(&self.assets, ".AppImage", Some(std::env::consts::ARCH))
143            }
144            InstallKind::Unknown => None,
145        }
146    }
147
148    /// The asset publishing `asset`'s SHA-256, if the release ships one.
149    ///
150    /// Recognises the two conventions in the wild: a per-asset digest file
151    /// (`Acme.AppImage.sha256`) and a listing covering the whole release
152    /// (`SHA256SUMS`, `checksums.txt`). A per-asset file wins, because it is
153    /// unambiguous about what it covers.
154    pub fn checksum_for(&self, asset: &ReleaseAsset) -> Option<&ReleaseAsset> {
155        let per_asset = [
156            format!("{}.sha256", asset.name),
157            format!("{}.sha256sum", asset.name),
158            format!("{}.SHA256", asset.name),
159        ];
160        if let Some(found) = self.assets.iter().find(|a| per_asset.contains(&a.name)) {
161            return Some(found);
162        }
163        self.assets.iter().find(|a| {
164            let name = a.name.to_ascii_lowercase();
165            matches!(
166                name.as_str(),
167                "sha256sums" | "sha256sums.txt" | "checksums.txt" | "checksums.sha256"
168            )
169        })
170    }
171
172    /// Whether this release has finished publishing what this machine needs.
173    ///
174    /// For an in-place install that means the exact asset. For everything else
175    /// the action is "open the download page", which needs no particular
176    /// artifact — so the only thing worth waiting for is the release having
177    /// *any* asset at all. Gating those on a per-OS asset instead would strand
178    /// anyone whose platform you don't publish for (a source build on riscv64,
179    /// say) on "still building" forever, never reaching the page fallback that
180    /// [`InstallKind::Unknown`] exists to provide.
181    pub fn ready_for(&self, kind: &InstallKind) -> bool {
182        match kind {
183            InstallKind::Unknown => !self.assets.is_empty(),
184            _ => self.asset_for(kind).is_some(),
185        }
186    }
187}
188
189/// Parse a release feed body into a [`Release`].
190fn parse(body: &[u8]) -> Result<Release, String> {
191    let value = json::parse(body).map_err(|e| format!("parse release: {e}"))?;
192    let tag = value
193        .get("tag_name")
194        .and_then(|v| v.as_str())
195        .ok_or("release has no tag")?;
196    let version = tag.trim_start_matches('v').to_string();
197    // The version becomes a path component of the staging directory, and
198    // `semver::parse` only reads the leading three fields — it would happily
199    // accept `1.28.0-/../../..`, which `create_dir_all` then resolves out of
200    // $TMPDIR. Nothing anyone ships tags that way, so refuse it rather than
201    // sanitize it.
202    if !version
203        .split('.')
204        .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
205    {
206        return Err(format!("refusing malformed release tag `{tag}`"));
207    }
208    let assets = value
209        .get("assets")
210        .and_then(|v| v.as_array())
211        .map(|items| {
212            items
213                .iter()
214                .filter_map(|item| {
215                    Some(ReleaseAsset {
216                        name: item.get("name")?.as_str()?.to_string(),
217                        url: item.get("browser_download_url")?.as_str()?.to_string(),
218                        size: item.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
219                    })
220                })
221                .collect()
222        })
223        .unwrap_or_default();
224    let url = value
225        .get("html_url")
226        .and_then(|v| v.as_str())
227        .unwrap_or_default()
228        .to_string();
229    Ok(Release {
230        version,
231        url,
232        assets,
233    })
234}
235
236/// Fetch the latest published release and classify it against the running
237/// version and this install. Blocking (spawns `curl`) — run it off the UI
238/// thread; [`super::Updater`] does that for you.
239pub(crate) fn check(
240    source: &UpdateSource,
241    user_agent: &str,
242    current: &str,
243    kind: &InstallKind,
244) -> Result<UpdateCheck, String> {
245    let release = parse(&fetch::bytes(&source.endpoint(), user_agent)?)?;
246    if !semver::is_newer(&release.version, current) {
247        return Ok(UpdateCheck::UpToDate);
248    }
249    if !release.ready_for(kind) {
250        return Ok(UpdateCheck::Pending(release.version));
251    }
252    Ok(UpdateCheck::Ready(release))
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use std::path::PathBuf;
259
260    /// A trimmed GitHub `releases/latest` response, with both AppImage
261    /// architectures so arch matching is exercised.
262    const BODY: &str = r#"{
263        "tag_name": "v1.26.0",
264        "html_url": "https://github.com/acme/acme/releases/tag/v1.26.0",
265        "assets": [
266            {"name": "Acme.dmg", "browser_download_url": "https://d/Acme.dmg", "size": 87357960},
267            {"name": "acme_1.26.0_amd64.deb", "browser_download_url": "https://d/deb", "size": 11977580},
268            {"name": "Acme-1.26.0-x86_64.AppImage", "browser_download_url": "https://d/intel", "size": 4},
269            {"name": "Acme-1.26.0-aarch64.AppImage", "browser_download_url": "https://d/arm", "size": 3}
270        ]
271    }"#;
272
273    fn mac() -> InstallKind {
274        InstallKind::MacApp(PathBuf::from("/Applications/Acme.app"))
275    }
276
277    fn appimage() -> InstallKind {
278        InstallKind::AppImage(PathBuf::from("/opt/Acme.AppImage"))
279    }
280
281    #[test]
282    fn release_json_parses() {
283        let release = parse(BODY.as_bytes()).unwrap();
284        assert_eq!(release.version, "1.26.0");
285        assert_eq!(
286            release.url,
287            "https://github.com/acme/acme/releases/tag/v1.26.0"
288        );
289        assert_eq!(release.assets.len(), 4);
290        assert_eq!(release.assets[0].size, 87_357_960);
291    }
292
293    #[test]
294    fn tagless_body_is_an_error() {
295        assert!(parse(br#"{"assets": []}"#).is_err());
296        assert!(parse(b"not json").is_err());
297    }
298
299    #[test]
300    fn missing_asset_fields_are_skipped() {
301        let release = parse(br#"{"tag_name": "v9.9.9", "assets": [{"name": "x"}]}"#).unwrap();
302        assert!(release.assets.is_empty());
303        assert!(release.url.is_empty());
304    }
305
306    #[test]
307    fn absent_size_field_defaults_to_zero() {
308        let body = br#"{"tag_name": "v9.9.9", "assets":
309            [{"name": "Acme.dmg", "browser_download_url": "https://d/x"}]}"#;
310        assert_eq!(parse(body).unwrap().assets[0].size, 0);
311    }
312
313    #[test]
314    fn mac_installs_take_the_universal_dmg() {
315        let release = parse(BODY.as_bytes()).unwrap();
316        assert_eq!(release.asset_for(&mac()).unwrap().url, "https://d/Acme.dmg");
317    }
318
319    #[test]
320    fn appimage_picks_the_running_architecture() {
321        // The fixture deliberately lists x86_64 *before* aarch64. Without arch
322        // matching, `pick` returns the first ".AppImage" it sees, so on an
323        // aarch64 host this assertion is what separates "matched my arch" from
324        // "took whatever was listed first" — the bug being guarded is renaming
325        // an image built for the other architecture over a working install.
326        let release = parse(BODY.as_bytes()).unwrap();
327        let want = if std::env::consts::ARCH == "aarch64" {
328            "https://d/arm"
329        } else {
330            "https://d/intel"
331        };
332        assert_eq!(release.asset_for(&appimage()).unwrap().url, want);
333    }
334
335    #[test]
336    fn debian_and_uname_arch_spellings_both_match() {
337        assert!(matches_arch("acme_1.26.0_arm64.deb", "aarch64"));
338        assert!(matches_arch("Acme-1.26.0-aarch64.AppImage", "aarch64"));
339        assert!(matches_arch("acme_1.26.0_amd64.deb", "x86_64"));
340        assert!(matches_arch("Acme-1.26.0-x86_64.AppImage", "x86_64"));
341        assert!(!matches_arch("Acme-1.26.0-aarch64.AppImage", "x86_64"));
342        assert!(!matches_arch("acme_1.26.0_amd64.deb", "aarch64"));
343    }
344
345    #[test]
346    fn arch_tokens_do_not_match_as_bare_substrings() {
347        // 32-bit `ARCH` values are substrings of the 64-bit asset names. Matching
348        // loosely would let an i686 or armv7 install download a 64-bit image and
349        // rename it over itself.
350        assert!(!matches_arch("Acme-1.26.0-x86_64.AppImage", "x86"));
351        assert!(!matches_arch("acme_1.26.0_arm64.deb", "arm"));
352        assert!(!matches_arch("Acme-1.26.0-aarch64.AppImage", "arm"));
353        // A genuine 32-bit artifact still matches its own name.
354        assert!(matches_arch("Acme-1.26.0-x86.AppImage", "x86"));
355        assert!(matches_arch("Acme-1.26.0-arm.AppImage", "arm"));
356    }
357
358    #[test]
359    fn malformed_release_tags_are_refused() {
360        // The version lands in the staging directory path, and version parsing
361        // reads only the leading fields, so a tag carrying `..` would escape
362        // $TMPDIR once `create_dir_all` resolved it.
363        assert!(parse(br#"{"tag_name": "v1.28.0-/../../../../pwned", "assets": []}"#).is_err());
364        assert!(parse(br#"{"tag_name": "v1.28.0/../x", "assets": []}"#).is_err());
365        assert!(parse(br#"{"tag_name": "v1.28.0-beta1", "assets": []}"#).is_err());
366        assert!(parse(br#"{"tag_name": "v1.28.0", "assets": []}"#).is_ok());
367    }
368
369    #[test]
370    fn unknown_installs_have_no_in_place_asset() {
371        let release = parse(BODY.as_bytes()).unwrap();
372        assert!(release.asset_for(&InstallKind::Unknown).is_none());
373    }
374
375    #[test]
376    fn a_release_with_our_asset_is_ready() {
377        let release = parse(BODY.as_bytes()).unwrap();
378        assert!(release.ready_for(&mac()));
379        assert!(release.ready_for(&appimage()));
380    }
381
382    #[test]
383    fn a_release_still_uploading_is_not_ready() {
384        // The shape a real release takes when it goes live with only the Linux
385        // artifacts while macOS notarization is still running. Offering this to
386        // a Mac produces an Update button that can only ever fail.
387        let body = br#"{"tag_name": "v1.27.8", "assets": [
388            {"name": "Acme-1.27.8-aarch64.AppImage", "browser_download_url": "https://d/a", "size": 1},
389            {"name": "acme_1.27.8_arm64.deb", "browser_download_url": "https://d/b", "size": 2}
390        ]}"#;
391        assert!(!parse(body).unwrap().ready_for(&mac()));
392    }
393
394    #[test]
395    fn a_release_with_no_assets_at_all_is_not_ready() {
396        let release = parse(br#"{"tag_name": "v9.9.9", "assets": []}"#).unwrap();
397        assert!(!release.ready_for(&mac()));
398        assert!(!release.ready_for(&appimage()));
399        assert!(!release.ready_for(&InstallKind::Unknown));
400    }
401
402    #[test]
403    fn installs_we_cannot_rewrite_are_never_stranded() {
404        // `Unknown` only ever opens the download page, so it must not be gated
405        // on an asset for this platform — a machine you publish nothing for
406        // would sit on "still building" forever and never reach the page.
407        let body = br#"{"tag_name": "v9.9.9", "assets": [
408            {"name": "something-for-another-platform.tar.gz", "browser_download_url": "https://d/x", "size": 1}
409        ]}"#;
410        assert!(parse(body).unwrap().ready_for(&InstallKind::Unknown));
411    }
412
413    #[test]
414    fn sources_resolve_to_their_endpoint() {
415        assert_eq!(
416            UpdateSource::github("acme/acme").endpoint(),
417            "https://api.github.com/repos/acme/acme/releases/latest"
418        );
419        assert_eq!(
420            UpdateSource::url("https://acme.dev/latest.json").endpoint(),
421            "https://acme.dev/latest.json"
422        );
423    }
424
425    #[test]
426    fn a_per_asset_digest_beats_a_release_wide_listing() {
427        let release = Release {
428            version: "1.0.0".to_string(),
429            url: String::new(),
430            assets: vec![
431                ReleaseAsset {
432                    name: "Acme-x86_64.AppImage".to_string(),
433                    url: "https://d/a".to_string(),
434                    size: 1,
435                },
436                ReleaseAsset {
437                    name: "SHA256SUMS".to_string(),
438                    url: "https://d/sums".to_string(),
439                    size: 1,
440                },
441                ReleaseAsset {
442                    name: "Acme-x86_64.AppImage.sha256".to_string(),
443                    url: "https://d/one".to_string(),
444                    size: 1,
445                },
446            ],
447        };
448        let asset = release.assets[0].clone();
449        assert_eq!(
450            release.checksum_for(&asset).map(|a| a.name.as_str()),
451            Some("Acme-x86_64.AppImage.sha256")
452        );
453    }
454
455    #[test]
456    fn a_release_wide_listing_is_the_fallback() {
457        let release = Release {
458            version: "1.0.0".to_string(),
459            url: String::new(),
460            assets: vec![
461                ReleaseAsset {
462                    name: "Acme.AppImage".to_string(),
463                    url: "https://d/a".to_string(),
464                    size: 1,
465                },
466                ReleaseAsset {
467                    name: "checksums.txt".to_string(),
468                    url: "https://d/sums".to_string(),
469                    size: 1,
470                },
471            ],
472        };
473        let asset = release.assets[0].clone();
474        assert_eq!(
475            release.checksum_for(&asset).map(|a| a.name.as_str()),
476            Some("checksums.txt")
477        );
478    }
479
480    #[test]
481    fn a_release_with_no_digest_reports_none() {
482        let release = Release {
483            version: "1.0.0".to_string(),
484            url: String::new(),
485            assets: vec![ReleaseAsset {
486                name: "Acme.AppImage".to_string(),
487                url: "https://d/a".to_string(),
488                size: 1,
489            }],
490        };
491        let asset = release.assets[0].clone();
492        assert!(release.checksum_for(&asset).is_none());
493    }
494}