use super::install::InstallKind;
use super::json;
use super::{fetch, semver};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum UpdateSource {
GitHub(String),
Url(String),
}
impl UpdateSource {
pub fn github(repo: impl Into<String>) -> Self {
UpdateSource::GitHub(repo.into())
}
pub fn url(url: impl Into<String>) -> Self {
UpdateSource::Url(url.into())
}
pub fn endpoint(&self) -> String {
match self {
UpdateSource::GitHub(repo) => {
format!("https://api.github.com/repos/{repo}/releases/latest")
}
UpdateSource::Url(url) => url.clone(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReleaseAsset {
pub name: String,
pub url: String,
pub size: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Release {
pub version: String,
pub url: String,
pub assets: Vec<ReleaseAsset>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum UpdateCheck {
UpToDate,
Pending(String),
Ready(Release),
}
fn matches_arch(name: &str, arch: &str) -> bool {
let aliases: &[&str] = match arch {
"aarch64" => &["aarch64", "arm64"],
"x86_64" => &["x86_64", "amd64"],
other => &[other],
};
aliases.iter().any(|alias| contains_token(name, alias))
}
fn contains_token(name: &str, token: &str) -> bool {
let bytes = name.as_bytes();
name.match_indices(token).any(|(i, _)| {
let before = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
let end = i + token.len();
let after = end == bytes.len() || (!bytes[end].is_ascii_alphanumeric() && bytes[end] != b'_');
before && after
})
}
fn pick<'a>(
assets: &'a [ReleaseAsset],
needle: &str,
arch: Option<&str>,
) -> Option<&'a ReleaseAsset> {
assets
.iter()
.find(|a| a.name.contains(needle) && arch.is_none_or(|x| matches_arch(&a.name, x)))
}
impl Release {
pub fn asset_for(&self, kind: &InstallKind) -> Option<&ReleaseAsset> {
match kind {
InstallKind::MacApp(_) => pick(&self.assets, ".dmg", None),
InstallKind::AppImage(_) => pick(&self.assets, ".AppImage", Some(std::env::consts::ARCH)),
InstallKind::Unknown => None,
}
}
pub fn checksum_for(&self, asset: &ReleaseAsset) -> Option<&ReleaseAsset> {
let per_asset = [
format!("{}.sha256", asset.name),
format!("{}.sha256sum", asset.name),
format!("{}.SHA256", asset.name),
];
if let Some(found) = self.assets.iter().find(|a| per_asset.contains(&a.name)) {
return Some(found);
}
self.assets.iter().find(|a| {
let name = a.name.to_ascii_lowercase();
matches!(
name.as_str(),
"sha256sums" | "sha256sums.txt" | "checksums.txt" | "checksums.sha256"
)
})
}
pub fn ready_for(&self, kind: &InstallKind) -> bool {
match kind {
InstallKind::Unknown => !self.assets.is_empty(),
_ => self.asset_for(kind).is_some(),
}
}
}
fn parse(body: &[u8]) -> Result<Release, String> {
let value = json::parse(body).map_err(|e| format!("parse release: {e}"))?;
let tag = value
.get("tag_name")
.and_then(|v| v.as_str())
.ok_or("release has no tag")?;
let version = tag.trim_start_matches('v').to_string();
if !version
.split('.')
.all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
{
return Err(format!("refusing malformed release tag `{tag}`"));
}
let assets = value
.get("assets")
.and_then(|v| v.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| {
Some(ReleaseAsset {
name: item.get("name")?.as_str()?.to_string(),
url: item.get("browser_download_url")?.as_str()?.to_string(),
size: item.get("size").and_then(|v| v.as_u64()).unwrap_or(0),
})
})
.collect()
})
.unwrap_or_default();
let url = value
.get("html_url")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
Ok(Release {
version,
url,
assets,
})
}
pub(crate) fn check(
source: &UpdateSource,
user_agent: &str,
current: &str,
kind: &InstallKind,
) -> Result<UpdateCheck, String> {
let release = parse(&fetch::bytes(&source.endpoint(), user_agent)?)?;
if !semver::is_newer(&release.version, current) {
return Ok(UpdateCheck::UpToDate);
}
if !release.ready_for(kind) {
return Ok(UpdateCheck::Pending(release.version));
}
Ok(UpdateCheck::Ready(release))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
const BODY: &str = r#"{
"tag_name": "v1.26.0",
"html_url": "https://github.com/acme/acme/releases/tag/v1.26.0",
"assets": [
{"name": "Acme.dmg", "browser_download_url": "https://d/Acme.dmg", "size": 87357960},
{"name": "acme_1.26.0_amd64.deb", "browser_download_url": "https://d/deb", "size": 11977580},
{"name": "Acme-1.26.0-x86_64.AppImage", "browser_download_url": "https://d/intel", "size": 4},
{"name": "Acme-1.26.0-aarch64.AppImage", "browser_download_url": "https://d/arm", "size": 3}
]
}"#;
fn mac() -> InstallKind {
InstallKind::MacApp(PathBuf::from("/Applications/Acme.app"))
}
fn appimage() -> InstallKind {
InstallKind::AppImage(PathBuf::from("/opt/Acme.AppImage"))
}
#[test]
fn release_json_parses() {
let release = parse(BODY.as_bytes()).unwrap();
assert_eq!(release.version, "1.26.0");
assert_eq!(
release.url,
"https://github.com/acme/acme/releases/tag/v1.26.0"
);
assert_eq!(release.assets.len(), 4);
assert_eq!(release.assets[0].size, 87_357_960);
}
#[test]
fn tagless_body_is_an_error() {
assert!(parse(br#"{"assets": []}"#).is_err());
assert!(parse(b"not json").is_err());
}
#[test]
fn missing_asset_fields_are_skipped() {
let release = parse(br#"{"tag_name": "v9.9.9", "assets": [{"name": "x"}]}"#).unwrap();
assert!(release.assets.is_empty());
assert!(release.url.is_empty());
}
#[test]
fn absent_size_field_defaults_to_zero() {
let body = br#"{"tag_name": "v9.9.9", "assets":
[{"name": "Acme.dmg", "browser_download_url": "https://d/x"}]}"#;
assert_eq!(parse(body).unwrap().assets[0].size, 0);
}
#[test]
fn mac_installs_take_the_universal_dmg() {
let release = parse(BODY.as_bytes()).unwrap();
assert_eq!(release.asset_for(&mac()).unwrap().url, "https://d/Acme.dmg");
}
#[test]
fn appimage_picks_the_running_architecture() {
let release = parse(BODY.as_bytes()).unwrap();
let want = if std::env::consts::ARCH == "aarch64" {
"https://d/arm"
} else {
"https://d/intel"
};
assert_eq!(release.asset_for(&appimage()).unwrap().url, want);
}
#[test]
fn debian_and_uname_arch_spellings_both_match() {
assert!(matches_arch("acme_1.26.0_arm64.deb", "aarch64"));
assert!(matches_arch("Acme-1.26.0-aarch64.AppImage", "aarch64"));
assert!(matches_arch("acme_1.26.0_amd64.deb", "x86_64"));
assert!(matches_arch("Acme-1.26.0-x86_64.AppImage", "x86_64"));
assert!(!matches_arch("Acme-1.26.0-aarch64.AppImage", "x86_64"));
assert!(!matches_arch("acme_1.26.0_amd64.deb", "aarch64"));
}
#[test]
fn arch_tokens_do_not_match_as_bare_substrings() {
assert!(!matches_arch("Acme-1.26.0-x86_64.AppImage", "x86"));
assert!(!matches_arch("acme_1.26.0_arm64.deb", "arm"));
assert!(!matches_arch("Acme-1.26.0-aarch64.AppImage", "arm"));
assert!(matches_arch("Acme-1.26.0-x86.AppImage", "x86"));
assert!(matches_arch("Acme-1.26.0-arm.AppImage", "arm"));
}
#[test]
fn malformed_release_tags_are_refused() {
assert!(parse(br#"{"tag_name": "v1.28.0-/../../../../pwned", "assets": []}"#).is_err());
assert!(parse(br#"{"tag_name": "v1.28.0/../x", "assets": []}"#).is_err());
assert!(parse(br#"{"tag_name": "v1.28.0-beta1", "assets": []}"#).is_err());
assert!(parse(br#"{"tag_name": "v1.28.0", "assets": []}"#).is_ok());
}
#[test]
fn unknown_installs_have_no_in_place_asset() {
let release = parse(BODY.as_bytes()).unwrap();
assert!(release.asset_for(&InstallKind::Unknown).is_none());
}
#[test]
fn a_release_with_our_asset_is_ready() {
let release = parse(BODY.as_bytes()).unwrap();
assert!(release.ready_for(&mac()));
assert!(release.ready_for(&appimage()));
}
#[test]
fn a_release_still_uploading_is_not_ready() {
let body = br#"{"tag_name": "v1.27.8", "assets": [
{"name": "Acme-1.27.8-aarch64.AppImage", "browser_download_url": "https://d/a", "size": 1},
{"name": "acme_1.27.8_arm64.deb", "browser_download_url": "https://d/b", "size": 2}
]}"#;
assert!(!parse(body).unwrap().ready_for(&mac()));
}
#[test]
fn a_release_with_no_assets_at_all_is_not_ready() {
let release = parse(br#"{"tag_name": "v9.9.9", "assets": []}"#).unwrap();
assert!(!release.ready_for(&mac()));
assert!(!release.ready_for(&appimage()));
assert!(!release.ready_for(&InstallKind::Unknown));
}
#[test]
fn installs_we_cannot_rewrite_are_never_stranded() {
let body = br#"{"tag_name": "v9.9.9", "assets": [
{"name": "something-for-another-platform.tar.gz", "browser_download_url": "https://d/x", "size": 1}
]}"#;
assert!(parse(body).unwrap().ready_for(&InstallKind::Unknown));
}
#[test]
fn sources_resolve_to_their_endpoint() {
assert_eq!(
UpdateSource::github("acme/acme").endpoint(),
"https://api.github.com/repos/acme/acme/releases/latest"
);
assert_eq!(
UpdateSource::url("https://acme.dev/latest.json").endpoint(),
"https://acme.dev/latest.json"
);
}
#[test]
fn a_per_asset_digest_beats_a_release_wide_listing() {
let release = Release {
version: "1.0.0".to_string(),
url: String::new(),
assets: vec![
ReleaseAsset {
name: "Acme-x86_64.AppImage".to_string(),
url: "https://d/a".to_string(),
size: 1,
},
ReleaseAsset {
name: "SHA256SUMS".to_string(),
url: "https://d/sums".to_string(),
size: 1,
},
ReleaseAsset {
name: "Acme-x86_64.AppImage.sha256".to_string(),
url: "https://d/one".to_string(),
size: 1,
},
],
};
let asset = release.assets[0].clone();
assert_eq!(
release.checksum_for(&asset).map(|a| a.name.as_str()),
Some("Acme-x86_64.AppImage.sha256")
);
}
#[test]
fn a_release_wide_listing_is_the_fallback() {
let release = Release {
version: "1.0.0".to_string(),
url: String::new(),
assets: vec![
ReleaseAsset {
name: "Acme.AppImage".to_string(),
url: "https://d/a".to_string(),
size: 1,
},
ReleaseAsset {
name: "checksums.txt".to_string(),
url: "https://d/sums".to_string(),
size: 1,
},
],
};
let asset = release.assets[0].clone();
assert_eq!(
release.checksum_for(&asset).map(|a| a.name.as_str()),
Some("checksums.txt")
);
}
#[test]
fn a_release_with_no_digest_reports_none() {
let release = Release {
version: "1.0.0".to_string(),
url: String::new(),
assets: vec![ReleaseAsset {
name: "Acme.AppImage".to_string(),
url: "https://d/a".to_string(),
size: 1,
}],
};
let asset = release.assets[0].clone();
assert!(release.checksum_for(&asset).is_none());
}
}