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 =
118 end == bytes.len() || (!bytes[end].is_ascii_alphanumeric() && bytes[end] != b'_');
119 before && after
120 })
121}
122
123fn 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 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 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 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
189fn 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 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
236pub(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 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 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 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 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 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 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 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}