Skip to main content

gpui_updater_core/source/
github.rs

1//! [`UpdateSource`] backed by the GitHub Releases API.
2
3use serde::Deserialize;
4
5use super::{UpdateSource, dates};
6use crate::error::{Error, Result};
7use crate::http::{self, HttpOptions, Metadata};
8use crate::release::{
9    Asset, DELTA_FORMAT_FILES_BSDIFF_V1, DeltaAsset, Release, parse_tag, web_page_url,
10};
11use crate::verify::sha256_from_sums;
12
13const API: &str = "https://api.github.com";
14
15/// Reads releases from a GitHub repository's Releases.
16///
17/// Binary deltas (see the `delta` feature) are discovered by name: for the
18/// selected asset `<asset>`, a release asset named `<asset>.from-<version>.delta`
19/// is a delta from `<version>`. Its checksum is read from the same checksums
20/// file and its signature from `<asset>.from-<version>.delta.minisig` (with the
21/// configured suffix). Delta files are never picked as the main asset. With
22/// [`delta_history`](Self::delta_history), deltas attached to earlier releases
23/// are listed too, so the engine can chain them.
24///
25/// ```no_run
26/// use gpui_updater_core::GitHubSource;
27///
28/// let source = GitHubSource::new("AprilNEA", "OpenLogi")
29///     .asset_contains("macos")
30///     .asset_contains(".dmg")
31///     .with_checksums("SHA256SUMS")
32///     .with_minisig();
33/// ```
34pub struct GitHubSource {
35    owner: String,
36    repo: String,
37    /// Case-insensitive substrings; an asset matches when its name contains all
38    /// of them. Defaults to a single extension guessed from the running OS.
39    asset_patterns: Vec<String>,
40    /// Name of a checksums file asset (e.g. `SHA256SUMS`) used to resolve the
41    /// expected SHA-256 of the selected asset.
42    sums_asset: Option<String>,
43    /// Suffix of the per-asset detached minisign signature (e.g. `.minisig`).
44    signature_suffix: Option<String>,
45    /// Whether to consider pre-releases as well as full releases.
46    include_prereleases: bool,
47    /// How many earlier releases to read deltas from.
48    delta_history: usize,
49    /// Optional token for private repos / higher rate limits.
50    token: Option<String>,
51    /// Extra headers and the proxy for API and checksum requests.
52    http: HttpOptions,
53}
54
55impl GitHubSource {
56    /// Create a source for `owner/repo`, matching assets by an extension
57    /// guessed from the current OS (`.dmg` / `.exe` / `.tar.gz`).
58    pub fn new(owner: impl Into<String>, repo: impl Into<String>) -> Self {
59        Self {
60            owner: owner.into(),
61            repo: repo.into(),
62            asset_patterns: vec![default_extension().to_string()],
63            sums_asset: None,
64            signature_suffix: None,
65            include_prereleases: false,
66            delta_history: 0,
67            token: None,
68            http: HttpOptions::new(),
69        }
70    }
71
72    /// Fetch the release list and the checksums file with `http`'s extra headers and proxy.
73    #[must_use]
74    pub fn http(mut self, http: HttpOptions) -> Self {
75        self.http = http;
76        self
77    }
78
79    /// Require the selected asset's name to contain `substr` (case-insensitive).
80    /// Call multiple times to require several substrings (e.g. `macos` + `arm64`).
81    #[must_use]
82    pub fn asset_contains(mut self, substr: impl Into<String>) -> Self {
83        self.asset_patterns.push(substr.into());
84        self
85    }
86
87    /// Replace the asset-matching substrings entirely.
88    #[must_use]
89    pub fn asset_patterns(mut self, patterns: Vec<String>) -> Self {
90        self.asset_patterns = patterns;
91        self
92    }
93
94    /// Resolve the expected SHA-256 from a checksums file asset (one
95    /// `<hex>  <name>` line per asset, as produced by `shasum -a 256`).
96    #[must_use]
97    pub fn with_checksums(mut self, sums_asset: impl Into<String>) -> Self {
98        self.sums_asset = Some(sums_asset.into());
99        self
100    }
101
102    /// Look for a detached minisign signature named `<asset>.minisig`.
103    #[must_use]
104    pub fn with_minisig(mut self) -> Self {
105        self.signature_suffix = Some(".minisig".to_string());
106        self
107    }
108
109    /// Use a custom signature suffix instead of `.minisig`.
110    #[must_use]
111    pub fn signature_suffix(mut self, suffix: impl Into<String>) -> Self {
112        self.signature_suffix = Some(suffix.into());
113        self
114    }
115
116    /// Consider pre-releases in addition to full releases.
117    #[must_use]
118    pub fn include_prereleases(mut self, yes: bool) -> Self {
119        self.include_prereleases = yes;
120        self
121    }
122
123    /// Authenticate requests with a token (private repos / rate limits).
124    #[must_use]
125    pub fn token(mut self, token: impl Into<String>) -> Self {
126        self.token = Some(token.into());
127        self
128    }
129
130    /// Also read the deltas attached to up to `releases` earlier releases, so
131    /// the engine can chain them when no delta starts at the installed version
132    /// (publish them with `delta::delta_bases`). This lists releases instead
133    /// of asking for `/releases/latest`: still one API request, covering up
134    /// to 100 releases.
135    #[must_use]
136    pub fn delta_history(mut self, releases: usize) -> Self {
137        self.delta_history = releases;
138        self
139    }
140
141    /// The release to offer, followed by up to `delta_history` earlier ones.
142    fn fetch_releases(&self, headers: &[(&str, &str)]) -> Result<Vec<GhRelease>> {
143        if !self.include_prereleases && self.delta_history == 0 {
144            let url = format!("{API}/repos/{}/{}/releases/latest", self.owner, self.repo);
145            return Ok(vec![http::get_json(&url, headers, &self.http)?]);
146        }
147        let per_page = (self.delta_history + 1).clamp(30, 100);
148        let url = format!(
149            "{API}/repos/{}/{}/releases?per_page={per_page}",
150            self.owner, self.repo
151        );
152        let list: Vec<GhRelease> = http::get_json(&url, headers, &self.http)?;
153        Ok(list
154            .into_iter()
155            .filter(|r| !r.draft && (self.include_prereleases || !r.prerelease))
156            .take(self.delta_history + 1)
157            .collect())
158    }
159}
160
161impl UpdateSource for GitHubSource {
162    fn fetch_latest(&self) -> Result<Release> {
163        let auth = self.token.as_ref().map(|t| format!("Bearer {t}"));
164        let mut headers: Vec<(&str, &str)> = vec![
165            ("accept", "application/vnd.github+json"),
166            ("x-github-api-version", "2022-11-28"),
167        ];
168        if let Some(auth) = &auth {
169            headers.push(("authorization", auth));
170        }
171
172        let releases = self.fetch_releases(&headers)?;
173        let Some((release, older)) = releases.split_first() else {
174            return Err(Error::Parse("repository has no releases".to_string()));
175        };
176        let version = parse_tag(&release.tag_name)?;
177        let main =
178            select_asset(&release.assets, &self.asset_patterns).ok_or(Error::NoMatchingAsset {
179                target_os: std::env::consts::OS,
180                target_arch: std::env::consts::ARCH,
181            })?;
182
183        let sums = match self.sums_asset_of(release) {
184            Some(sums) => Some(http::get_string(
185                &sums.browser_download_url,
186                &headers,
187                Metadata::Sidecar,
188                &self.http,
189            )?),
190            None => None,
191        };
192        let sha256 = sums
193            .as_deref()
194            .and_then(|text| sha256_from_sums(text, &main.name));
195        let mut deltas = self.deltas_of(release, main, &Sums::Fetched(sums.as_deref()));
196        // Earlier releases' deltas let the engine chain several hops. Their
197        // checksums are only fetched if a chain actually uses them.
198        for old in older {
199            if let Some(old_main) = select_asset(&old.assets, &self.asset_patterns) {
200                let url = self
201                    .sums_asset_of(old)
202                    .map(|a| a.browser_download_url.clone());
203                deltas.extend(self.deltas_of(old, old_main, &Sums::Deferred(url)));
204            }
205        }
206
207        Ok(Release {
208            version,
209            notes: release
210                .body
211                .clone()
212                .filter(|s| !s.trim().is_empty())
213                .or_else(|| release.name.clone()),
214            asset: Asset {
215                name: main.name.clone(),
216                url: main.browser_download_url.clone(),
217                size: main.size,
218            },
219            signature: None,
220            signature_url: self.signature_for(release, &main.name),
221            sha256,
222            ed25519_signature: None,
223            deltas,
224            published_at: release.published_at.as_deref().and_then(dates::rfc3339),
225            release_url: web_page_url(release.html_url.as_deref()),
226        })
227    }
228}
229
230/// Where the checksums of a release's deltas come from.
231enum Sums<'a> {
232    /// The release's checksums file, already downloaded.
233    Fetched(Option<&'a str>),
234    /// The URL of the release's checksums file, for the engine to fetch if
235    /// it uses one of these deltas.
236    Deferred(Option<String>),
237}
238
239impl GitHubSource {
240    fn signature_for(&self, release: &GhRelease, name: &str) -> Option<String> {
241        let suffix = self.signature_suffix.as_ref()?;
242        let want = format!("{name}{suffix}");
243        release
244            .assets
245            .iter()
246            .find(|a| a.name == want)
247            .map(|a| a.browser_download_url.clone())
248    }
249
250    fn sums_asset_of<'a>(&self, release: &'a GhRelease) -> Option<&'a GhAsset> {
251        let name = self.sums_asset.as_ref()?;
252        release.assets.iter().find(|a| &a.name == name)
253    }
254
255    /// The deltas attached to `release`, whose platform asset is `main`.
256    fn deltas_of(&self, release: &GhRelease, main: &GhAsset, sums: &Sums<'_>) -> Vec<DeltaAsset> {
257        let Ok(to) = parse_tag(&release.tag_name) else {
258            return Vec::new();
259        };
260        release
261            .assets
262            .iter()
263            .filter_map(|a| {
264                let from = delta_from(&main.name, &a.name)?;
265                let (sha256, checksums_url) = match sums {
266                    Sums::Fetched(text) => (text.and_then(|t| sha256_from_sums(t, &a.name)), None),
267                    Sums::Deferred(url) => (None, url.clone()),
268                };
269                Some(DeltaAsset {
270                    from,
271                    to: to.clone(),
272                    format: DELTA_FORMAT_FILES_BSDIFF_V1.to_string(),
273                    asset: Asset {
274                        name: a.name.clone(),
275                        url: a.browser_download_url.clone(),
276                        size: a.size,
277                    },
278                    sha256,
279                    checksums_url,
280                    signature: None,
281                    signature_url: self.signature_for(release, &a.name),
282                    ed25519_signature: None,
283                })
284            })
285            .collect()
286    }
287}
288
289fn default_extension() -> &'static str {
290    match std::env::consts::OS {
291        "macos" => ".dmg",
292        "windows" => ".exe",
293        _ => ".tar.gz",
294    }
295}
296
297/// True when `name` contains every pattern (case-insensitive).
298fn asset_matches(name: &str, patterns: &[String]) -> bool {
299    let lower = name.to_ascii_lowercase();
300    patterns
301        .iter()
302        .all(|p| lower.contains(&p.to_ascii_lowercase()))
303}
304
305fn select_asset<'a>(assets: &'a [GhAsset], patterns: &[String]) -> Option<&'a GhAsset> {
306    assets
307        .iter()
308        .find(|a| !is_delta_asset(&a.name) && asset_matches(&a.name, patterns))
309}
310
311/// Delta files and their signatures, which must never be the main asset even
312/// though their names contain the main asset's name.
313fn is_delta_asset(name: &str) -> bool {
314    let lower = name.to_ascii_lowercase();
315    lower.rsplit('.').next() == Some("delta") || lower.contains(".delta.")
316}
317
318/// The `from` version when `candidate` is `<main>.from-<version>.delta`.
319fn delta_from(main: &str, candidate: &str) -> Option<semver::Version> {
320    let version = candidate
321        .strip_prefix(main)?
322        .strip_prefix(".from-")?
323        .strip_suffix(".delta")?;
324    parse_tag(version).ok()
325}
326
327#[derive(Debug, Deserialize)]
328struct GhRelease {
329    tag_name: String,
330    #[serde(default)]
331    name: Option<String>,
332    #[serde(default)]
333    body: Option<String>,
334    #[serde(default)]
335    draft: bool,
336    #[serde(default)]
337    prerelease: bool,
338    /// The release's page on github.com.
339    #[serde(default)]
340    html_url: Option<String>,
341    /// RFC 3339; `null` for a draft.
342    #[serde(default)]
343    published_at: Option<String>,
344    #[serde(default)]
345    assets: Vec<GhAsset>,
346}
347
348#[derive(Debug, Deserialize)]
349struct GhAsset {
350    name: String,
351    browser_download_url: String,
352    #[serde(default)]
353    size: u64,
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    fn pats(p: &[&str]) -> Vec<String> {
361        p.iter().map(ToString::to_string).collect()
362    }
363
364    #[test]
365    fn asset_matching_requires_all_substrings_case_insensitively() {
366        assert!(asset_matches(
367            "OpenLogi-v0.2.0-macOS.DMG",
368            &pats(&["macos", ".dmg"])
369        ));
370        assert!(!asset_matches(
371            "OpenLogi-v0.2.0-linux.tar.gz",
372            &pats(&["macos"])
373        ));
374        assert!(asset_matches(
375            "app-arm64-macos.dmg",
376            &pats(&["arm64", "macos"])
377        ));
378        assert!(!asset_matches(
379            "app-x86_64-macos.dmg",
380            &pats(&["arm64", "macos"])
381        ));
382    }
383
384    #[test]
385    fn select_picks_first_matching_asset() {
386        let assets = vec![
387            GhAsset {
388                name: "SHA256SUMS".into(),
389                browser_download_url: "u0".into(),
390                size: 0,
391            },
392            GhAsset {
393                name: "app-linux.tar.gz".into(),
394                browser_download_url: "u1".into(),
395                size: 1,
396            },
397            GhAsset {
398                name: "app-macos.dmg".into(),
399                browser_download_url: "u2".into(),
400                size: 2,
401            },
402        ];
403        let picked = select_asset(&assets, &pats(&[".dmg"])).unwrap();
404        assert_eq!(picked.name, "app-macos.dmg");
405        assert!(select_asset(&assets, &pats(&[".exe"])).is_none());
406    }
407
408    #[test]
409    fn delta_assets_are_matched_by_name_and_never_selected_as_main() {
410        let assets = vec![
411            GhAsset {
412                name: "app-macos.dmg.from-0.8.12.delta".into(),
413                browser_download_url: "d".into(),
414                size: 10,
415            },
416            GhAsset {
417                name: "app-macos.dmg.from-0.8.12.delta.minisig".into(),
418                browser_download_url: "s".into(),
419                size: 1,
420            },
421            GhAsset {
422                name: "app-macos.dmg".into(),
423                browser_download_url: "m".into(),
424                size: 100,
425            },
426        ];
427        let picked = select_asset(&assets, &pats(&["macos", ".dmg"])).unwrap();
428        assert_eq!(picked.name, "app-macos.dmg");
429
430        assert_eq!(
431            delta_from("app-macos.dmg", "app-macos.dmg.from-0.8.12.delta").map(|v| v.to_string()),
432            Some("0.8.12".to_string())
433        );
434        assert_eq!(
435            delta_from("app-macos.dmg", "app-macos.dmg.from-v1.0.0-rc.1.delta")
436                .map(|v| v.to_string()),
437            Some("1.0.0-rc.1".to_string())
438        );
439        assert!(delta_from("app-macos.dmg", "app-macos.dmg.from-0.8.12.delta.minisig").is_none());
440        assert!(delta_from("app-macos.dmg", "app-linux.tar.gz.from-0.8.12.delta").is_none());
441        assert!(delta_from("app-macos.dmg", "app-macos.dmg.from-latest.delta").is_none());
442    }
443
444    fn gh_release(tag: &str, names: &[&str]) -> GhRelease {
445        GhRelease {
446            tag_name: tag.into(),
447            name: None,
448            body: None,
449            draft: false,
450            prerelease: false,
451            html_url: None,
452            published_at: None,
453            assets: names
454                .iter()
455                .map(|n| GhAsset {
456                    name: (*n).into(),
457                    browser_download_url: format!("https://x/{tag}/{n}"),
458                    size: 7,
459                })
460                .collect(),
461        }
462    }
463
464    #[test]
465    fn lists_deltas_of_earlier_releases_with_deferred_checksums() {
466        let source = GitHubSource::new("o", "r")
467            .asset_patterns(pats(&["macos", ".dmg"]))
468            .with_checksums("SHA256SUMS")
469            .with_minisig();
470        let old = gh_release(
471            "v0.8.12",
472            &[
473                "app-0.8.12-macos.dmg",
474                "app-0.8.12-macos.dmg.from-0.8.11.delta",
475                "app-0.8.12-macos.dmg.from-0.8.11.delta.minisig",
476                "app-0.8.12-linux.tar.gz.from-0.8.11.delta",
477                "SHA256SUMS",
478            ],
479        );
480        let main = select_asset(&old.assets, &source.asset_patterns).unwrap();
481        let deferred = Sums::Deferred(Some("https://x/v0.8.12/SHA256SUMS".into()));
482        let deltas = source.deltas_of(&old, main, &deferred);
483        assert_eq!(deltas.len(), 1);
484        let d = &deltas[0];
485        assert_eq!(
486            (d.from.to_string(), d.to.to_string()),
487            ("0.8.11".into(), "0.8.12".into())
488        );
489        assert_eq!(d.sha256, None);
490        assert_eq!(
491            d.checksums_url.as_deref(),
492            Some("https://x/v0.8.12/SHA256SUMS")
493        );
494        assert_eq!(
495            d.signature_url.as_deref(),
496            Some("https://x/v0.8.12/app-0.8.12-macos.dmg.from-0.8.11.delta.minisig")
497        );
498
499        let sums = "abc  app-0.8.12-macos.dmg.from-0.8.11.delta\n";
500        let fetched = source.deltas_of(&old, main, &Sums::Fetched(Some(sums)));
501        assert_eq!(fetched[0].sha256.as_deref(), Some("abc"));
502        assert_eq!(fetched[0].checksums_url, None);
503    }
504
505    #[test]
506    fn deserializes_github_release_json() {
507        let json = r#"{
508            "tag_name": "v0.2.0",
509            "name": "Release 0.2.0",
510            "body": "notes here",
511            "draft": false,
512            "html_url": "https://github.com/o/r/releases/tag/v0.2.0",
513            "published_at": "2026-10-11T12:34:56Z",
514            "assets": [
515                {"name": "app-macos.dmg", "browser_download_url": "https://x/app-macos.dmg", "size": 1234}
516            ]
517        }"#;
518        let r: GhRelease = serde_json::from_str(json).unwrap();
519        assert_eq!(r.tag_name, "v0.2.0");
520        assert_eq!(
521            web_page_url(r.html_url.as_deref()).as_deref(),
522            Some("https://github.com/o/r/releases/tag/v0.2.0")
523        );
524        assert!(r.published_at.as_deref().and_then(dates::rfc3339).is_some());
525        // Drafts have `"published_at": null`.
526        let draft: GhRelease =
527            serde_json::from_str(r#"{"tag_name": "v0.3.0", "published_at": null}"#).unwrap();
528        assert_eq!(draft.published_at, None);
529        assert_eq!(r.assets.len(), 1);
530        assert_eq!(r.assets[0].size, 1234);
531        assert_eq!(parse_tag(&r.tag_name).unwrap().minor, 2);
532    }
533}