1#![allow(unsafe_code)]
42
43use std::sync::Arc;
44
45use async_trait::async_trait;
46use linkme::distributed_slice;
47use secrecy::{ExposeSecret, SecretString};
48use tokio::io::AsyncRead;
49
50use crate::config::ReleaseSourceConfig;
51use crate::http;
52use crate::release::{
53 ProviderError, ProviderFactory, ProviderRegistration, RegisteredProvider, Release,
54 ReleaseAsset, ReleaseProvider, RELEASE_PROVIDERS,
55};
56
57pub struct GithubProvider {
61 client: reqwest::Client,
62 scheme: &'static str,
66 host: String,
67 owner: String,
68 repo: String,
69 token: Option<SecretString>,
72}
73
74fn normalise_host(raw: &str) -> String {
92 let stripped =
93 raw.trim_end_matches('/').trim_start_matches("https://").trim_start_matches("http://");
94 if stripped == "api.github.com" || stripped.ends_with("/api/v3") || stripped.contains("/api/") {
95 stripped.to_string()
96 } else if let Some(rest) = stripped.strip_prefix("api.") {
97 format!("{rest}/api/v3")
98 } else {
99 format!("{stripped}/api/v3")
100 }
101}
102
103pub fn factory(
116 cfg: &ReleaseSourceConfig,
117 token: Option<SecretString>,
118) -> Result<Arc<dyn ReleaseProvider>, ProviderError> {
119 let ReleaseSourceConfig::Github(params) = cfg else {
120 return Err(ProviderError::InvalidConfig(format!(
121 "github factory called with non-github config: source_type={}",
122 cfg.source_type()
123 )));
124 };
125
126 if params.host.trim().is_empty() {
127 return Err(ProviderError::InvalidConfig("github host must not be empty".to_string()));
128 }
129 if params.host.starts_with("http://") {
130 return Err(ProviderError::InvalidConfig(format!(
131 "github host must be https; got {}",
132 params.host
133 )));
134 }
135 if params.owner.trim().is_empty() || params.repo.trim().is_empty() {
136 return Err(ProviderError::InvalidConfig(
137 "github owner and repo must not be empty".to_string(),
138 ));
139 }
140
141 let host = if params.allow_insecure_base_url {
147 params.host.trim_end_matches('/').to_string()
148 } else {
149 normalise_host(¶ms.host)
150 };
151 let client = http::build_client(params.timeout_seconds, params.allow_insecure_base_url)?;
152 let scheme = http::scheme_for(params.allow_insecure_base_url);
153
154 Ok(Arc::new(GithubProvider {
155 client,
156 scheme,
157 host,
158 owner: params.owner.clone(),
159 repo: params.repo.clone(),
160 token,
161 }))
162}
163
164#[distributed_slice(RELEASE_PROVIDERS)]
167fn __register_github() -> Box<dyn ProviderRegistration> {
168 Box::new(RegisteredProvider { source_type: "github", factory: factory as ProviderFactory })
169}
170
171#[async_trait]
176impl ReleaseProvider for GithubProvider {
177 async fn latest_release(&self) -> Result<Release, ProviderError> {
178 let url = format!(
179 "{scheme}://{host}/repos/{owner}/{repo}/releases/latest",
180 scheme = self.scheme,
181 host = self.host,
182 owner = self.owner,
183 repo = self.repo
184 );
185 let resp = self.send(&url).await?;
186 let dto: ApiRelease = http::parse_json(resp).await?;
187 Ok(dto.into_release())
188 }
189
190 async fn release_by_tag(&self, tag: &str) -> Result<Release, ProviderError> {
191 let url = format!(
192 "{scheme}://{host}/repos/{owner}/{repo}/releases/tags/{tag}",
193 scheme = self.scheme,
194 host = self.host,
195 owner = self.owner,
196 repo = self.repo,
197 tag = http::urlencode(tag),
198 );
199 let resp = self.send(&url).await?;
200 let dto: ApiRelease = http::parse_json(resp).await?;
201 Ok(dto.into_release())
202 }
203
204 async fn list_releases(&self, limit: usize) -> Result<Vec<Release>, ProviderError> {
205 let per_page = limit.clamp(1, 100);
209 let url = format!(
210 "{scheme}://{host}/repos/{owner}/{repo}/releases?per_page={per_page}",
211 scheme = self.scheme,
212 host = self.host,
213 owner = self.owner,
214 repo = self.repo
215 );
216 let resp = self.send(&url).await?;
217 let dtos: Vec<ApiRelease> = http::parse_json(resp).await?;
218 Ok(dtos.into_iter().take(limit).map(ApiRelease::into_release).collect())
219 }
220
221 async fn download_asset(
222 &self,
223 asset: &ReleaseAsset,
224 ) -> Result<(Box<dyn AsyncRead + Send + Unpin>, u64), ProviderError> {
225 let mut req = self
228 .client
229 .get(&asset.download_url)
230 .header("Accept", "application/octet-stream")
231 .header("X-GitHub-Api-Version", "2022-11-28");
232 if let Some(tok) = &self.token {
233 req = req.bearer_auth(tok.expose_secret());
234 }
235 let resp = req.send().await.map_err(|e| ProviderError::Transport(e.to_string()))?;
236 check_status(&resp, &self.host)?;
237 Ok(http::stream_body(resp))
238 }
239}
240
241impl GithubProvider {
246 async fn send(&self, url: &str) -> Result<reqwest::Response, ProviderError> {
247 let mut req = self
248 .client
249 .get(url)
250 .header("Accept", "application/vnd.github+json")
251 .header("X-GitHub-Api-Version", "2022-11-28");
252 if let Some(tok) = &self.token {
253 req = req.bearer_auth(tok.expose_secret());
254 }
255 let resp = req.send().await.map_err(|e| ProviderError::Transport(e.to_string()))?;
256 check_status(&resp, &self.host)?;
257 Ok(resp)
258 }
259}
260
261fn check_status(resp: &reqwest::Response, host: &str) -> Result<(), ProviderError> {
265 let extra_rate_limit_signal = resp.status() == reqwest::StatusCode::FORBIDDEN
266 && http::header_str(resp.headers(), "x-ratelimit-remaining") == Some("0");
267 http::map_status_to_error(resp, host, extra_rate_limit_signal)
268}
269
270#[derive(Debug, serde::Deserialize)]
275struct ApiRelease {
276 #[serde(default)]
277 id: u64,
278 #[serde(default)]
279 name: Option<String>,
280 tag_name: String,
281 #[serde(default)]
282 body: Option<String>,
283 #[serde(default)]
284 draft: bool,
285 #[serde(default)]
286 prerelease: bool,
287 created_at: String,
288 #[serde(default)]
289 published_at: Option<String>,
290 #[serde(default)]
291 assets: Vec<ApiAsset>,
292}
293
294#[derive(Debug, serde::Deserialize)]
295struct ApiAsset {
296 id: u64,
297 name: String,
298 #[serde(default)]
299 size: u64,
300 #[serde(default)]
301 content_type: Option<String>,
302 #[serde(rename = "browser_download_url")]
303 download_url: String,
304}
305
306impl ApiRelease {
307 fn into_release(self) -> Release {
308 let created_at =
309 parse_iso8601(&self.created_at).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
310 let published_at = self.published_at.as_deref().and_then(parse_iso8601);
311 let name = self.name.unwrap_or_else(|| self.tag_name.clone());
312 let body = self.body.unwrap_or_default();
313 let tag = self.tag_name.clone();
314 let _ = self.id;
315 let mut release = Release::new(name, tag, created_at);
316 release.body = body;
317 release.draft = self.draft;
318 release.prerelease = self.prerelease;
319 release.published_at = published_at;
320 release.assets = self.assets.into_iter().map(ApiAsset::into_asset).collect();
321 release
322 }
323}
324
325impl ApiAsset {
326 fn into_asset(self) -> ReleaseAsset {
327 let mut a = ReleaseAsset::new(self.id.to_string(), self.name, self.download_url);
328 a.size = self.size;
329 a.content_type = self.content_type;
330 a
331 }
332}
333
334fn parse_iso8601(s: &str) -> Option<time::OffsetDateTime> {
335 time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
336}
337
338#[cfg(test)]
344mod tests {
345 use super::normalise_host;
346
347 #[test]
348 fn normalises_api_github_com() {
349 assert_eq!(normalise_host("api.github.com"), "api.github.com");
350 assert_eq!(normalise_host("https://api.github.com"), "api.github.com");
351 assert_eq!(normalise_host("https://api.github.com/"), "api.github.com");
352 }
353
354 #[test]
355 fn promotes_bare_enterprise_host() {
356 assert_eq!(normalise_host("github.example.com"), "github.example.com/api/v3");
357 assert_eq!(normalise_host("https://github.example.com/"), "github.example.com/api/v3");
358 }
359
360 #[test]
361 fn promotes_api_prefixed_enterprise_host() {
362 assert_eq!(normalise_host("api.github.example.com"), "github.example.com/api/v3");
364 }
365
366 #[test]
367 fn preserves_explicit_api_path() {
368 assert_eq!(normalise_host("github.example.com/api/v3"), "github.example.com/api/v3");
369 }
370}