Skip to main content

release_hub/source/
github.rs

1//! GitHub Release-backed source adapter.
2
3use crate::{
4    Error, InstallerKind, ReleaseManifestPlatform, ReleaseSource, RemoteRelease,
5    RemoteReleaseInner, Result, SourceFuture, SourceRequest,
6};
7use http::header::{ACCEPT, AUTHORIZATION};
8use http::{HeaderMap, HeaderValue};
9use octocrab::{
10    Octocrab,
11    models::repos::{Asset, Release},
12};
13use semver::Version;
14use serde_json::json;
15use std::{collections::HashMap, path::Path};
16use time::OffsetDateTime;
17
18#[derive(Debug, Clone)]
19struct FixtureRelease {
20    version: String,
21    assets: Vec<FixtureAsset>,
22}
23
24impl ReleaseSource for GitHubSource {
25    fn fetch<'a>(&'a self, request: &'a SourceRequest) -> SourceFuture<'a> {
26        Box::pin(async move { self.release_source_impl(request).await })
27    }
28}
29
30#[derive(Debug, Clone)]
31struct FixtureAsset {
32    name: String,
33    value: String,
34}
35
36#[derive(Debug, Clone)]
37enum SignatureSource<'a> {
38    Download(&'a Asset),
39    Fixture(&'a str),
40}
41
42/// Release source backed by the latest GitHub Release of a repository.
43///
44/// Assets are matched by target marker in the filename, and each installer
45/// asset must have a sibling `.sig` or `.minisig` asset with the same base
46/// name.
47#[derive(Debug, Clone)]
48pub struct GitHubSource {
49    client: octocrab::Octocrab,
50    owner: String,
51    repo: String,
52    fixture_release: Option<FixtureRelease>,
53    asset_headers: HeaderMap,
54}
55
56impl GitHubSource {
57    /// Creates a GitHub-backed release source for production use.
58    ///
59    /// Anonymous requests are subject to GitHub API rate limits and only work
60    /// for public repositories.
61    pub fn new(owner: impl Into<String>, repo: impl Into<String>) -> Self {
62        Self {
63            client: Octocrab::default(),
64            owner: owner.into(),
65            repo: repo.into(),
66            fixture_release: None,
67            asset_headers: HeaderMap::new(),
68        }
69    }
70
71    /// Creates a GitHub-backed source that authenticates requests with a personal access token.
72    ///
73    /// This enables private-repository releases and higher GitHub API rate limits. The same
74    /// token is propagated to release-asset and signature downloads handled by the updater.
75    pub fn with_auth_token(
76        owner: impl Into<String>,
77        repo: impl Into<String>,
78        token: impl AsRef<str>,
79    ) -> Result<Self> {
80        let token = token.as_ref();
81        let client = Octocrab::builder().personal_token(token).build().unwrap();
82        let mut asset_headers = HeaderMap::new();
83        asset_headers.insert(
84            AUTHORIZATION,
85            HeaderValue::from_str(&format!("Bearer {token}"))?,
86        );
87
88        Ok(Self {
89            client,
90            owner: owner.into(),
91            repo: repo.into(),
92            fixture_release: None,
93            asset_headers,
94        })
95    }
96
97    /// Creates a GitHub-backed source from a custom Octocrab client.
98    ///
99    /// Use this when you need a preconfigured GitHub client with custom
100    /// middleware, base URLs, or authentication strategy.
101    pub fn with_client(
102        owner: impl Into<String>,
103        repo: impl Into<String>,
104        client: Octocrab,
105    ) -> Self {
106        Self {
107            client,
108            owner: owner.into(),
109            repo: repo.into(),
110            fixture_release: None,
111            asset_headers: HeaderMap::new(),
112        }
113    }
114
115    /// Builds a fixture-backed source for tests that need deterministic assets
116    /// without hitting the GitHub API.
117    ///
118    /// This helper is intentionally test-oriented. Production code should use
119    /// [`GitHubSource::new`] so signatures are fetched from the real paired
120    /// release asset.
121    #[doc(hidden)]
122    pub fn from_assets(
123        owner: impl Into<String>,
124        repo: impl Into<String>,
125        version: &str,
126        assets: Vec<(&str, &str)>,
127    ) -> Self {
128        Self {
129            client: Octocrab::default(),
130            owner: owner.into(),
131            repo: repo.into(),
132            fixture_release: Some(FixtureRelease {
133                version: version.into(),
134                assets: assets
135                    .into_iter()
136                    .map(|(name, value)| FixtureAsset {
137                        name: name.into(),
138                        value: value.into(),
139                    })
140                    .collect(),
141            }),
142            asset_headers: HeaderMap::new(),
143        }
144    }
145
146    /// Fetches and adapts the latest GitHub release into the crate's neutral release model.
147    pub(crate) async fn release_source_impl(
148        &self,
149        request: &SourceRequest,
150    ) -> Result<RemoteRelease> {
151        if let Some(fixture_release) = &self.fixture_release {
152            let asset = select_fixture_target_asset(&fixture_release.assets, &request.target)?;
153            let signature_asset =
154                find_fixture_signature_asset(&fixture_release.assets, &asset.name)
155                    .ok_or_else(|| Error::MissingSignatureAsset(asset.name.clone()))?;
156            let download_asset = fixture_download_asset(asset, 1);
157
158            return build_remote_release_from_assets(
159                &request.target,
160                &fixture_release.version,
161                None,
162                None,
163                &download_asset,
164                SignatureSource::Fixture(&signature_asset.value),
165                &HeaderMap::new(),
166            )
167            .await;
168        }
169
170        let release = self
171            .client
172            .repos(&self.owner, &self.repo)
173            .releases()
174            .get_latest()
175            .await?;
176        let pub_date = parse_pub_date(&release)?;
177        let asset = select_target_asset(&release.assets, &request.target)?;
178        let signature_asset = find_signature_asset(&release.assets, &asset.name)
179            .ok_or_else(|| Error::MissingSignatureAsset(asset.name.clone()))?;
180
181        build_remote_release_from_assets(
182            &request.target,
183            &release.tag_name,
184            release.body.clone(),
185            pub_date,
186            asset,
187            SignatureSource::Download(signature_asset),
188            &self.asset_headers,
189        )
190        .await
191    }
192}
193
194fn fixture_asset(id: u64, name: &str, url: &str) -> Asset {
195    serde_json::from_value(json!({
196        "url": format!("https://api.github.com/assets/{id}"),
197        "browser_download_url": url,
198        "id": id,
199        "node_id": format!("asset-{id}"),
200        "name": name,
201        "label": null,
202        "state": "uploaded",
203        "content_type": "application/octet-stream",
204        "size": 1,
205        "digest": null,
206        "download_count": 0,
207        "created_at": "2026-04-21T00:00:00Z",
208        "updated_at": "2026-04-21T00:00:00Z",
209        "uploader": null
210    }))
211    .expect("fixture asset should deserialize")
212}
213
214fn fixture_download_asset(asset: &FixtureAsset, id: u64) -> Asset {
215    fixture_asset(id, &asset.name, &asset.value)
216}
217
218fn is_signature_asset(name: &str) -> bool {
219    name.ends_with(".sig") || name.ends_with(".minisig")
220}
221
222fn target_variants(target: &str) -> [String; 3] {
223    [
224        target.to_ascii_lowercase(),
225        target.replace('-', "_").to_ascii_lowercase(),
226        target.replace('_', "-").to_ascii_lowercase(),
227    ]
228}
229
230fn select_target_asset<'a>(assets: &'a [Asset], target: &str) -> Result<&'a Asset> {
231    let variants = target_variants(target);
232    assets
233        .iter()
234        .filter(|asset| !is_signature_asset(&asset.name))
235        .find(|asset| {
236            let name = asset.name.to_ascii_lowercase();
237            variants.iter().any(|variant| name.contains(variant))
238                && InstallerKind::from_path(Path::new(&asset.name)).is_ok()
239        })
240        .ok_or_else(|| Error::TargetNotFound(target.into()))
241}
242
243fn select_fixture_target_asset<'a>(
244    assets: &'a [FixtureAsset],
245    target: &str,
246) -> Result<&'a FixtureAsset> {
247    let variants = target_variants(target);
248    assets
249        .iter()
250        .filter(|asset| !is_signature_asset(&asset.name))
251        .find(|asset| {
252            let name = asset.name.to_ascii_lowercase();
253            variants.iter().any(|variant| name.contains(variant))
254                && InstallerKind::from_path(Path::new(&asset.name)).is_ok()
255        })
256        .ok_or_else(|| Error::TargetNotFound(target.into()))
257}
258
259fn find_signature_asset<'a>(assets: &'a [Asset], name: &str) -> Option<&'a Asset> {
260    let sig_name = format!("{name}.sig");
261    let minisig_name = format!("{name}.minisig");
262    assets
263        .iter()
264        .find(|asset| asset.name == sig_name || asset.name == minisig_name)
265}
266
267fn find_fixture_signature_asset<'a>(
268    assets: &'a [FixtureAsset],
269    name: &str,
270) -> Option<&'a FixtureAsset> {
271    let sig_name = format!("{name}.sig");
272    let minisig_name = format!("{name}.minisig");
273    assets
274        .iter()
275        .find(|asset| asset.name == sig_name || asset.name == minisig_name)
276}
277
278fn parse_release_version(version: &str) -> Result<Version> {
279    Version::parse(version.trim_start_matches('v')).map_err(Error::Semver)
280}
281
282fn parse_pub_date(release: &Release) -> Result<Option<OffsetDateTime>> {
283    release
284        .published_at
285        .as_ref()
286        .map(|published_at| {
287            OffsetDateTime::parse(
288                &published_at.to_rfc3339(),
289                &time::format_description::well_known::Rfc3339,
290            )
291            .map_err(Error::Time)
292        })
293        .transpose()
294}
295
296async fn load_signature(source: SignatureSource<'_>, asset_headers: &HeaderMap) -> Result<String> {
297    match source {
298        SignatureSource::Download(signature_asset) => {
299            let download_url = if asset_headers.is_empty() {
300                signature_asset.browser_download_url.clone()
301            } else {
302                signature_asset.url.clone()
303            };
304
305            let mut headers = asset_headers.clone();
306            headers.insert(ACCEPT, HeaderValue::from_static("application/octet-stream"));
307
308            Ok(reqwest::Client::new()
309                .get(download_url)
310                .headers(headers)
311                .send()
312                .await?
313                .error_for_status()?
314                .text()
315                .await?)
316        }
317        SignatureSource::Fixture(signature) => Ok(signature.to_string()),
318    }
319}
320
321async fn build_remote_release_from_assets(
322    target: &str,
323    version: &str,
324    notes: Option<String>,
325    pub_date: Option<OffsetDateTime>,
326    asset: &Asset,
327    signature_source: SignatureSource<'_>,
328    asset_headers: &HeaderMap,
329) -> Result<RemoteRelease> {
330    let signature = load_signature(signature_source, asset_headers).await?;
331    let download_url = if asset_headers.is_empty() {
332        asset.browser_download_url.clone()
333    } else {
334        asset.url.clone()
335    };
336    let platforms = HashMap::from([(
337        target.to_string(),
338        ReleaseManifestPlatform {
339            url: download_url,
340            signature,
341        },
342    )]);
343
344    Ok(RemoteRelease {
345        version: parse_release_version(version)?,
346        notes,
347        pub_date,
348        data: RemoteReleaseInner::Static { platforms },
349        download_headers: asset_headers.clone(),
350    })
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[tokio::test]
358    async fn with_auth_token_preserves_repository_identity() {
359        let source = GitHubSource::with_auth_token("owner-name", "repo-name", "test-token")
360            .expect("token-backed source should build");
361
362        assert_eq!(source.owner, "owner-name");
363        assert_eq!(source.repo, "repo-name");
364        assert!(source.fixture_release.is_none());
365        assert!(source.asset_headers.contains_key(AUTHORIZATION));
366    }
367}