1use 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#[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 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 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 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 #[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 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}