1use 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
15pub struct GitHubSource {
35 owner: String,
36 repo: String,
37 asset_patterns: Vec<String>,
40 sums_asset: Option<String>,
43 signature_suffix: Option<String>,
45 include_prereleases: bool,
47 delta_history: usize,
49 token: Option<String>,
51 http: HttpOptions,
53}
54
55impl GitHubSource {
56 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 #[must_use]
74 pub fn http(mut self, http: HttpOptions) -> Self {
75 self.http = http;
76 self
77 }
78
79 #[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 #[must_use]
89 pub fn asset_patterns(mut self, patterns: Vec<String>) -> Self {
90 self.asset_patterns = patterns;
91 self
92 }
93
94 #[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 #[must_use]
104 pub fn with_minisig(mut self) -> Self {
105 self.signature_suffix = Some(".minisig".to_string());
106 self
107 }
108
109 #[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 #[must_use]
118 pub fn include_prereleases(mut self, yes: bool) -> Self {
119 self.include_prereleases = yes;
120 self
121 }
122
123 #[must_use]
125 pub fn token(mut self, token: impl Into<String>) -> Self {
126 self.token = Some(token.into());
127 self
128 }
129
130 #[must_use]
136 pub fn delta_history(mut self, releases: usize) -> Self {
137 self.delta_history = releases;
138 self
139 }
140
141 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 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
230enum Sums<'a> {
232 Fetched(Option<&'a str>),
234 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 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
297fn 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
311fn is_delta_asset(name: &str) -> bool {
314 let lower = name.to_ascii_lowercase();
315 lower.rsplit('.').next() == Some("delta") || lower.contains(".delta.")
316}
317
318fn 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 #[serde(default)]
340 html_url: Option<String>,
341 #[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 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}