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 = end == bytes.len() || (!bytes[end].is_ascii_alphanumeric() && bytes[end] != b'_');
118    before && after
119  })
120}
121
122/// The first asset whose name contains `needle`, optionally restricted to assets
123/// built for `arch`.
124fn pick<'a>(
125  assets: &'a [ReleaseAsset],
126  needle: &str,
127  arch: Option<&str>,
128) -> Option<&'a ReleaseAsset> {
129  assets
130    .iter()
131    .find(|a| a.name.contains(needle) && arch.is_none_or(|x| matches_arch(&a.name, x)))
132}
133
134impl Release {
135  /// The download this install would fetch, if the release has published it.
136  /// `None` for [`InstallKind::Unknown`], which has no in-place path at all.
137  pub fn asset_for(&self, kind: &InstallKind) -> Option<&ReleaseAsset> {
138    match kind {
139      InstallKind::MacApp(_) => pick(&self.assets, ".dmg", None),
140      InstallKind::AppImage(_) => pick(&self.assets, ".AppImage", Some(std::env::consts::ARCH)),
141      InstallKind::Unknown => None,
142    }
143  }
144
145  /// The asset publishing `asset`'s SHA-256, if the release ships one.
146  ///
147  /// Recognises the two conventions in the wild: a per-asset digest file
148  /// (`Acme.AppImage.sha256`) and a listing covering the whole release
149  /// (`SHA256SUMS`, `checksums.txt`). A per-asset file wins, because it is
150  /// unambiguous about what it covers.
151  pub fn checksum_for(&self, asset: &ReleaseAsset) -> Option<&ReleaseAsset> {
152    let per_asset = [
153      format!("{}.sha256", asset.name),
154      format!("{}.sha256sum", asset.name),
155      format!("{}.SHA256", asset.name),
156    ];
157    if let Some(found) = self.assets.iter().find(|a| per_asset.contains(&a.name)) {
158      return Some(found);
159    }
160    self.assets.iter().find(|a| {
161      let name = a.name.to_ascii_lowercase();
162      matches!(
163        name.as_str(),
164        "sha256sums" | "sha256sums.txt" | "checksums.txt" | "checksums.sha256"
165      )
166    })
167  }
168
169  /// Whether this release has finished publishing what this machine needs.
170  ///
171  /// For an in-place install that means the exact asset. For everything else
172  /// the action is "open the download page", which needs no particular
173  /// artifact — so the only thing worth waiting for is the release having
174  /// *any* asset at all. Gating those on a per-OS asset instead would strand
175  /// anyone whose platform you don't publish for (a source build on riscv64,
176  /// say) on "still building" forever, never reaching the page fallback that
177  /// [`InstallKind::Unknown`] exists to provide.
178  pub fn ready_for(&self, kind: &InstallKind) -> bool {
179    match kind {
180      InstallKind::Unknown => !self.assets.is_empty(),
181      _ => self.asset_for(kind).is_some(),
182    }
183  }
184}
185
186/// Parse a release feed body into a [`Release`].
187fn parse(body: &[u8]) -> Result<Release, String> {
188  let value = json::parse(body).map_err(|e| format!("parse release: {e}"))?;
189  let tag = value
190    .get("tag_name")
191    .and_then(|v| v.as_str())
192    .ok_or("release has no tag")?;
193  let version = tag.trim_start_matches('v').to_string();
194  // The version becomes a path component of the staging directory, and
195  // `semver::parse` only reads the leading three fields — it would happily
196  // accept `1.28.0-/../../..`, which `create_dir_all` then resolves out of
197  // $TMPDIR. Nothing anyone ships tags that way, so refuse it rather than
198  // sanitize it.
199  if !version
200    .split('.')
201    .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
202  {
203    return Err(format!("refusing malformed release tag `{tag}`"));
204  }
205  let assets = value
206    .get("assets")
207    .and_then(|v| v.as_array())
208    .map(|items| {
209      items
210        .iter()
211        .filter_map(|item| {
212          Some(ReleaseAsset {
213            name: item.get("name")?.as_str()?.to_string(),
214            url: item.get("browser_download_url")?.as_str()?.to_string(),
215            size: item.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
216          })
217        })
218        .collect()
219    })
220    .unwrap_or_default();
221  let url = value
222    .get("html_url")
223    .and_then(|v| v.as_str())
224    .unwrap_or_default()
225    .to_string();
226  Ok(Release {
227    version,
228    url,
229    assets,
230  })
231}
232
233/// Fetch the latest published release and classify it against the running
234/// version and this install. Blocking (spawns `curl`) — run it off the UI
235/// thread; [`super::Updater`] does that for you.
236pub(crate) fn check(
237  source: &UpdateSource,
238  user_agent: &str,
239  current: &str,
240  kind: &InstallKind,
241) -> Result<UpdateCheck, String> {
242  let release = parse(&fetch::bytes(&source.endpoint(), user_agent)?)?;
243  if !semver::is_newer(&release.version, current) {
244    return Ok(UpdateCheck::UpToDate);
245  }
246  if !release.ready_for(kind) {
247    return Ok(UpdateCheck::Pending(release.version));
248  }
249  Ok(UpdateCheck::Ready(release))
250}
251
252#[cfg(test)]
253mod tests {
254  use super::*;
255  use std::path::PathBuf;
256
257  /// A trimmed GitHub `releases/latest` response, with both AppImage
258  /// architectures so arch matching is exercised.
259  const BODY: &str = r#"{
260        "tag_name": "v1.26.0",
261        "html_url": "https://github.com/acme/acme/releases/tag/v1.26.0",
262        "assets": [
263            {"name": "Acme.dmg", "browser_download_url": "https://d/Acme.dmg", "size": 87357960},
264            {"name": "acme_1.26.0_amd64.deb", "browser_download_url": "https://d/deb", "size": 11977580},
265            {"name": "Acme-1.26.0-x86_64.AppImage", "browser_download_url": "https://d/intel", "size": 4},
266            {"name": "Acme-1.26.0-aarch64.AppImage", "browser_download_url": "https://d/arm", "size": 3}
267        ]
268    }"#;
269
270  fn mac() -> InstallKind {
271    InstallKind::MacApp(PathBuf::from("/Applications/Acme.app"))
272  }
273
274  fn appimage() -> InstallKind {
275    InstallKind::AppImage(PathBuf::from("/opt/Acme.AppImage"))
276  }
277
278  #[test]
279  fn release_json_parses() {
280    let release = parse(BODY.as_bytes()).unwrap();
281    assert_eq!(release.version, "1.26.0");
282    assert_eq!(
283      release.url,
284      "https://github.com/acme/acme/releases/tag/v1.26.0"
285    );
286    assert_eq!(release.assets.len(), 4);
287    assert_eq!(release.assets[0].size, 87_357_960);
288  }
289
290  #[test]
291  fn tagless_body_is_an_error() {
292    assert!(parse(br#"{"assets": []}"#).is_err());
293    assert!(parse(b"not json").is_err());
294  }
295
296  #[test]
297  fn missing_asset_fields_are_skipped() {
298    let release = parse(br#"{"tag_name": "v9.9.9", "assets": [{"name": "x"}]}"#).unwrap();
299    assert!(release.assets.is_empty());
300    assert!(release.url.is_empty());
301  }
302
303  #[test]
304  fn absent_size_field_defaults_to_zero() {
305    let body = br#"{"tag_name": "v9.9.9", "assets":
306            [{"name": "Acme.dmg", "browser_download_url": "https://d/x"}]}"#;
307    assert_eq!(parse(body).unwrap().assets[0].size, 0);
308  }
309
310  #[test]
311  fn mac_installs_take_the_universal_dmg() {
312    let release = parse(BODY.as_bytes()).unwrap();
313    assert_eq!(release.asset_for(&mac()).unwrap().url, "https://d/Acme.dmg");
314  }
315
316  #[test]
317  fn appimage_picks_the_running_architecture() {
318    // The fixture deliberately lists x86_64 *before* aarch64. Without arch
319    // matching, `pick` returns the first ".AppImage" it sees, so on an
320    // aarch64 host this assertion is what separates "matched my arch" from
321    // "took whatever was listed first" — the bug being guarded is renaming
322    // an image built for the other architecture over a working install.
323    let release = parse(BODY.as_bytes()).unwrap();
324    let want = if std::env::consts::ARCH == "aarch64" {
325      "https://d/arm"
326    } else {
327      "https://d/intel"
328    };
329    assert_eq!(release.asset_for(&appimage()).unwrap().url, want);
330  }
331
332  #[test]
333  fn debian_and_uname_arch_spellings_both_match() {
334    assert!(matches_arch("acme_1.26.0_arm64.deb", "aarch64"));
335    assert!(matches_arch("Acme-1.26.0-aarch64.AppImage", "aarch64"));
336    assert!(matches_arch("acme_1.26.0_amd64.deb", "x86_64"));
337    assert!(matches_arch("Acme-1.26.0-x86_64.AppImage", "x86_64"));
338    assert!(!matches_arch("Acme-1.26.0-aarch64.AppImage", "x86_64"));
339    assert!(!matches_arch("acme_1.26.0_amd64.deb", "aarch64"));
340  }
341
342  #[test]
343  fn arch_tokens_do_not_match_as_bare_substrings() {
344    // 32-bit `ARCH` values are substrings of the 64-bit asset names. Matching
345    // loosely would let an i686 or armv7 install download a 64-bit image and
346    // rename it over itself.
347    assert!(!matches_arch("Acme-1.26.0-x86_64.AppImage", "x86"));
348    assert!(!matches_arch("acme_1.26.0_arm64.deb", "arm"));
349    assert!(!matches_arch("Acme-1.26.0-aarch64.AppImage", "arm"));
350    // A genuine 32-bit artifact still matches its own name.
351    assert!(matches_arch("Acme-1.26.0-x86.AppImage", "x86"));
352    assert!(matches_arch("Acme-1.26.0-arm.AppImage", "arm"));
353  }
354
355  #[test]
356  fn malformed_release_tags_are_refused() {
357    // The version lands in the staging directory path, and version parsing
358    // reads only the leading fields, so a tag carrying `..` would escape
359    // $TMPDIR once `create_dir_all` resolved it.
360    assert!(parse(br#"{"tag_name": "v1.28.0-/../../../../pwned", "assets": []}"#).is_err());
361    assert!(parse(br#"{"tag_name": "v1.28.0/../x", "assets": []}"#).is_err());
362    assert!(parse(br#"{"tag_name": "v1.28.0-beta1", "assets": []}"#).is_err());
363    assert!(parse(br#"{"tag_name": "v1.28.0", "assets": []}"#).is_ok());
364  }
365
366  #[test]
367  fn unknown_installs_have_no_in_place_asset() {
368    let release = parse(BODY.as_bytes()).unwrap();
369    assert!(release.asset_for(&InstallKind::Unknown).is_none());
370  }
371
372  #[test]
373  fn a_release_with_our_asset_is_ready() {
374    let release = parse(BODY.as_bytes()).unwrap();
375    assert!(release.ready_for(&mac()));
376    assert!(release.ready_for(&appimage()));
377  }
378
379  #[test]
380  fn a_release_still_uploading_is_not_ready() {
381    // The shape a real release takes when it goes live with only the Linux
382    // artifacts while macOS notarization is still running. Offering this to
383    // a Mac produces an Update button that can only ever fail.
384    let body = br#"{"tag_name": "v1.27.8", "assets": [
385            {"name": "Acme-1.27.8-aarch64.AppImage", "browser_download_url": "https://d/a", "size": 1},
386            {"name": "acme_1.27.8_arm64.deb", "browser_download_url": "https://d/b", "size": 2}
387        ]}"#;
388    assert!(!parse(body).unwrap().ready_for(&mac()));
389  }
390
391  #[test]
392  fn a_release_with_no_assets_at_all_is_not_ready() {
393    let release = parse(br#"{"tag_name": "v9.9.9", "assets": []}"#).unwrap();
394    assert!(!release.ready_for(&mac()));
395    assert!(!release.ready_for(&appimage()));
396    assert!(!release.ready_for(&InstallKind::Unknown));
397  }
398
399  #[test]
400  fn installs_we_cannot_rewrite_are_never_stranded() {
401    // `Unknown` only ever opens the download page, so it must not be gated
402    // on an asset for this platform — a machine you publish nothing for
403    // would sit on "still building" forever and never reach the page.
404    let body = br#"{"tag_name": "v9.9.9", "assets": [
405            {"name": "something-for-another-platform.tar.gz", "browser_download_url": "https://d/x", "size": 1}
406        ]}"#;
407    assert!(parse(body).unwrap().ready_for(&InstallKind::Unknown));
408  }
409
410  #[test]
411  fn sources_resolve_to_their_endpoint() {
412    assert_eq!(
413      UpdateSource::github("acme/acme").endpoint(),
414      "https://api.github.com/repos/acme/acme/releases/latest"
415    );
416    assert_eq!(
417      UpdateSource::url("https://acme.dev/latest.json").endpoint(),
418      "https://acme.dev/latest.json"
419    );
420  }
421
422  #[test]
423  fn a_per_asset_digest_beats_a_release_wide_listing() {
424    let release = Release {
425      version: "1.0.0".to_string(),
426      url: String::new(),
427      assets: vec![
428        ReleaseAsset {
429          name: "Acme-x86_64.AppImage".to_string(),
430          url: "https://d/a".to_string(),
431          size: 1,
432        },
433        ReleaseAsset {
434          name: "SHA256SUMS".to_string(),
435          url: "https://d/sums".to_string(),
436          size: 1,
437        },
438        ReleaseAsset {
439          name: "Acme-x86_64.AppImage.sha256".to_string(),
440          url: "https://d/one".to_string(),
441          size: 1,
442        },
443      ],
444    };
445    let asset = release.assets[0].clone();
446    assert_eq!(
447      release.checksum_for(&asset).map(|a| a.name.as_str()),
448      Some("Acme-x86_64.AppImage.sha256")
449    );
450  }
451
452  #[test]
453  fn a_release_wide_listing_is_the_fallback() {
454    let release = Release {
455      version: "1.0.0".to_string(),
456      url: String::new(),
457      assets: vec![
458        ReleaseAsset {
459          name: "Acme.AppImage".to_string(),
460          url: "https://d/a".to_string(),
461          size: 1,
462        },
463        ReleaseAsset {
464          name: "checksums.txt".to_string(),
465          url: "https://d/sums".to_string(),
466          size: 1,
467        },
468      ],
469    };
470    let asset = release.assets[0].clone();
471    assert_eq!(
472      release.checksum_for(&asset).map(|a| a.name.as_str()),
473      Some("checksums.txt")
474    );
475  }
476
477  #[test]
478  fn a_release_with_no_digest_reports_none() {
479    let release = Release {
480      version: "1.0.0".to_string(),
481      url: String::new(),
482      assets: vec![ReleaseAsset {
483        name: "Acme.AppImage".to_string(),
484        url: "https://d/a".to_string(),
485        size: 1,
486      }],
487    };
488    let asset = release.assets[0].clone();
489    assert!(release.checksum_for(&asset).is_none());
490  }
491}