1use super::install::InstallKind;
14use super::json;
15use super::{fetch, semver};
16
17#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum UpdateSource {
26 GitHub(String),
28 Url(String),
30}
31
32impl UpdateSource {
33 pub fn github(repo: impl Into<String>) -> Self {
35 UpdateSource::GitHub(repo.into())
36 }
37
38 pub fn url(url: impl Into<String>) -> Self {
40 UpdateSource::Url(url.into())
41 }
42
43 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#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct ReleaseAsset {
57 pub name: String,
59 pub url: String,
61 pub size: u64,
64}
65
66#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct Release {
69 pub version: String,
71 pub url: String,
73 pub assets: Vec<ReleaseAsset>,
75}
76
77#[derive(Clone, Debug, PartialEq, Eq)]
81pub enum UpdateCheck {
82 UpToDate,
84 Pending(String),
87 Ready(Release),
89}
90
91fn 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
103fn 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
122fn 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 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 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 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
186fn 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 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
233pub(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 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 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 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 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 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 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 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}