1use std::{env, sync::LazyLock};
2
3use percent_encoding::percent_decode_str;
4use regex::Regex;
5use ureq::http::header::AUTHORIZATION;
6use url::Url;
7
8use crate::{error::DownloadError, http_client::SHARED_AGENT};
9
10#[derive(Debug)]
11pub enum PlatformUrl {
12 Github {
13 project: String,
14 tag: Option<String>,
15 },
16 Gitlab {
17 project: String,
18 tag: Option<String>,
19 },
20 Oci {
21 reference: String,
22 },
23 Direct {
24 url: String,
25 },
26}
27
28static GITHUB_RE: LazyLock<Regex> = LazyLock::new(|| {
29 Regex::new(r"^(?i)(?:https?://)?(?:github(?:\.com)?[:/])([^/@]+/[^/@]+)(?:@([^\r\n]+))?$")
30 .expect("unable to compile github release regex")
31});
32
33static GITLAB_RE: LazyLock<Regex> = LazyLock::new(|| {
34 Regex::new(
35 r"^(?i)(?:https?://)?(?:gitlab(?:\.com)?[:/])((?:\d+)|(?:[^/@]+(?:/[^/@]+)*))(?:@([^\r\n]+))?$",
36 )
37 .expect("unable to compile gitlab release regex")
38});
39
40impl PlatformUrl {
41 pub fn parse(url: impl AsRef<str>) -> Option<Self> {
67 let url = url.as_ref();
68
69 let normalized = url
70 .trim_start_matches("https://")
71 .trim_start_matches("http://");
72 if normalized.starts_with("ghcr.io/") {
73 return Some(Self::Oci {
74 reference: normalized.to_string(),
75 });
76 }
77
78 if let Some((project, tag)) = Self::parse_repo(&GITHUB_RE, url) {
79 return Some(Self::Github {
80 project,
81 tag,
82 });
83 }
84
85 if let Some((project, tag)) = Self::parse_repo(&GITLAB_RE, url) {
86 if project.starts_with("api/") || project.contains("/-/") {
87 return Url::parse(url).ok().map(|_| {
88 Self::Direct {
89 url: url.to_string(),
90 }
91 });
92 }
93 return Some(Self::Gitlab {
94 project,
95 tag,
96 });
97 }
98
99 Url::parse(url)
100 .ok()
101 .filter(|u| !u.scheme().is_empty() && u.host().is_some())
102 .map(|_| {
103 Self::Direct {
104 url: url.to_string(),
105 }
106 })
107 }
108
109 fn parse_repo(re: &Regex, url: &str) -> Option<(String, Option<String>)> {
115 let caps = re.captures(url)?;
116 let project = caps.get(1)?.as_str().to_string();
117 let tag = caps
118 .get(2)
119 .map(|m| m.as_str().trim_matches(&['\'', '"', ' '][..]))
120 .filter(|s| !s.is_empty())
121 .and_then(|s| {
122 percent_decode_str(s)
123 .decode_utf8()
124 .ok()
125 .map(|cow| cow.into_owned())
126 });
127
128 Some((project, tag))
129 }
130}
131
132pub fn fetch_releases_json<T>(
141 path: &str,
142 base: &str,
143 token_env: [&str; 2],
144) -> Result<Vec<T>, DownloadError>
145where
146 T: serde::de::DeserializeOwned,
147{
148 let url = format!("{}{}", base, path);
149 let mut req = SHARED_AGENT.get(&url);
150
151 if let Ok(token) = env::var(token_env[0]).or_else(|_| env::var(token_env[1])) {
152 req = req.header(AUTHORIZATION, &format!("Bearer {}", token.trim()));
153 }
154
155 let mut resp = req.call()?;
156 let status = resp.status();
157
158 if !status.is_success() {
159 return Err(DownloadError::HttpError {
160 status: status.as_u16(),
161 url: url.clone(),
162 });
163 }
164
165 let json: serde_json::Value = resp
166 .body_mut()
167 .read_json()
168 .map_err(|_| DownloadError::InvalidResponse)?;
169
170 match json {
171 serde_json::Value::Array(_) => {
172 serde_json::from_value(json).map_err(|_| DownloadError::InvalidResponse)
173 }
174 serde_json::Value::Object(_) => {
175 let single: T =
176 serde_json::from_value(json).map_err(|_| DownloadError::InvalidResponse)?;
177 Ok(vec![single])
178 }
179 _ => Err(DownloadError::InvalidResponse),
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn test_platform_url_parse_oci() {
189 let result = PlatformUrl::parse("ghcr.io/owner/repo:latest");
190 match result {
191 Some(PlatformUrl::Oci {
192 reference,
193 }) => {
194 assert_eq!(reference, "ghcr.io/owner/repo:latest");
195 }
196 _ => panic!("Expected OCI variant"),
197 }
198 }
199
200 #[test]
201 fn test_platform_url_parse_oci_with_prefix() {
202 let result = PlatformUrl::parse("https://ghcr.io/owner/repo:v1.0");
203 match result {
204 Some(PlatformUrl::Oci {
205 reference,
206 }) => {
207 assert_eq!(reference, "ghcr.io/owner/repo:v1.0");
208 }
209 _ => panic!("Expected OCI variant"),
210 }
211 }
212
213 #[test]
214 fn test_platform_url_parse_github_https() {
215 let result = PlatformUrl::parse("https://github.com/owner/repo");
216 match result {
217 Some(PlatformUrl::Github {
218 project,
219 tag,
220 }) => {
221 assert_eq!(project, "owner/repo");
222 assert_eq!(tag, None);
223 }
224 _ => panic!("Expected Github variant"),
225 }
226 }
227
228 #[test]
229 fn test_platform_url_parse_github_with_tag() {
230 let result = PlatformUrl::parse("https://github.com/owner/repo@v1.0.0");
231 match result {
232 Some(PlatformUrl::Github {
233 project,
234 tag,
235 }) => {
236 assert_eq!(project, "owner/repo");
237 assert_eq!(tag, Some("v1.0.0".to_string()));
238 }
239 _ => panic!("Expected Github variant with tag"),
240 }
241 }
242
243 #[test]
244 fn test_platform_url_parse_github_shorthand() {
245 let result = PlatformUrl::parse("github:owner/repo");
246 match result {
247 Some(PlatformUrl::Github {
248 project,
249 tag,
250 }) => {
251 assert_eq!(project, "owner/repo");
252 assert_eq!(tag, None);
253 }
254 _ => panic!("Expected Github variant"),
255 }
256 }
257
258 #[test]
259 fn test_platform_url_parse_github_case_insensitive() {
260 let result = PlatformUrl::parse("GITHUB.COM/owner/repo");
261 match result {
262 Some(PlatformUrl::Github {
263 project,
264 tag,
265 }) => {
266 assert_eq!(project, "owner/repo");
267 assert_eq!(tag, None);
268 }
269 _ => panic!("Expected Github variant"),
270 }
271 }
272
273 #[test]
274 fn test_platform_url_parse_gitlab_https() {
275 let result = PlatformUrl::parse("https://gitlab.com/owner/repo");
276 match result {
277 Some(PlatformUrl::Gitlab {
278 project,
279 tag,
280 }) => {
281 assert_eq!(project, "owner/repo");
282 assert_eq!(tag, None);
283 }
284 _ => panic!("Expected Gitlab variant"),
285 }
286 }
287
288 #[test]
289 fn test_platform_url_parse_gitlab_with_tag() {
290 let result = PlatformUrl::parse("https://gitlab.com/owner/repo@v2.0");
291 match result {
292 Some(PlatformUrl::Gitlab {
293 project,
294 tag,
295 }) => {
296 assert_eq!(project, "owner/repo");
297 assert_eq!(tag, Some("v2.0".to_string()));
298 }
299 _ => panic!("Expected Gitlab variant with tag"),
300 }
301 }
302
303 #[test]
304 fn test_platform_url_parse_gitlab_numeric_project() {
305 let result = PlatformUrl::parse("https://gitlab.com/12345@v1.0");
306 match result {
307 Some(PlatformUrl::Gitlab {
308 project,
309 tag,
310 }) => {
311 assert_eq!(project, "12345");
312 assert_eq!(tag, Some("v1.0".to_string()));
313 }
314 _ => panic!("Expected Gitlab variant with numeric project"),
315 }
316 }
317
318 #[test]
319 fn test_platform_url_parse_gitlab_nested_groups() {
320 let result = PlatformUrl::parse("https://gitlab.com/group/subgroup/repo");
321 match result {
322 Some(PlatformUrl::Gitlab {
323 project,
324 tag,
325 }) => {
326 assert_eq!(project, "group/subgroup/repo");
327 assert_eq!(tag, None);
328 }
329 _ => panic!("Expected Gitlab variant with nested groups"),
330 }
331 }
332
333 #[test]
334 fn test_platform_url_parse_gitlab_api_path_as_direct() {
335 let result = PlatformUrl::parse("https://gitlab.com/api/v4/projects/123");
336 match result {
337 Some(PlatformUrl::Direct {
338 url,
339 }) => {
340 assert_eq!(url, "https://gitlab.com/api/v4/projects/123");
341 }
342 _ => panic!("Expected Direct variant for API path"),
343 }
344 }
345
346 #[test]
347 fn test_platform_url_parse_gitlab_special_path_as_direct() {
348 let result = PlatformUrl::parse("https://gitlab.com/owner/repo/-/releases");
349 match result {
350 Some(PlatformUrl::Direct {
351 url,
352 }) => {
353 assert_eq!(url, "https://gitlab.com/owner/repo/-/releases");
354 }
355 _ => panic!("Expected Direct variant for special path"),
356 }
357 }
358
359 #[test]
360 fn test_platform_url_parse_direct_url() {
361 let result = PlatformUrl::parse("https://example.com/download/file.tar.gz");
362 match result {
363 Some(PlatformUrl::Direct {
364 url,
365 }) => {
366 assert_eq!(url, "https://example.com/download/file.tar.gz");
367 }
368 _ => panic!("Expected Direct variant"),
369 }
370 }
371
372 #[test]
373 fn test_platform_url_parse_direct_http() {
374 let result = PlatformUrl::parse("http://example.com/file.zip");
375 match result {
376 Some(PlatformUrl::Direct {
377 url,
378 }) => {
379 assert_eq!(url, "http://example.com/file.zip");
380 }
381 _ => panic!("Expected Direct variant"),
382 }
383 }
384
385 #[test]
386 fn test_platform_url_parse_invalid() {
387 assert!(PlatformUrl::parse("not a valid url").is_none());
388 assert!(PlatformUrl::parse("").is_none());
389 assert!(PlatformUrl::parse("/not/a/url").is_none());
390 }
391
392 #[test]
393 fn test_platform_url_parse_github_with_spaces_in_tag() {
394 let result = PlatformUrl::parse("github.com/owner/repo@v1.0 beta");
395 match result {
396 Some(PlatformUrl::Github {
397 project,
398 tag,
399 }) => {
400 assert_eq!(project, "owner/repo");
401 assert_eq!(tag, Some("v1.0 beta".to_string()));
402 }
403 _ => panic!("Expected Github variant with tag containing spaces"),
404 }
405 }
406
407 #[test]
408 fn test_platform_url_parse_tag_with_special_chars() {
409 let result = PlatformUrl::parse("github.com/owner/repo@v1.0-rc.1+build.123");
410 match result {
411 Some(PlatformUrl::Github {
412 project,
413 tag,
414 }) => {
415 assert_eq!(project, "owner/repo");
416 assert_eq!(tag, Some("v1.0-rc.1+build.123".to_string()));
417 }
418 _ => panic!("Expected Github variant with complex tag"),
419 }
420 }
421
422 #[test]
423 fn test_parse_repo_with_quotes() {
424 let result = PlatformUrl::parse("github.com/owner/repo@'v1.0'");
425 match result {
426 Some(PlatformUrl::Github {
427 project,
428 tag,
429 }) => {
430 assert_eq!(project, "owner/repo");
431 assert_eq!(tag, Some("v1.0".to_string()));
432 }
433 _ => panic!("Expected quotes to be stripped from tag"),
434 }
435 }
436
437 #[test]
438 fn test_parse_repo_percent_encoded_tag() {
439 let result = PlatformUrl::parse("github.com/owner/repo@v1.0%2Bbuild");
440 match result {
441 Some(PlatformUrl::Github {
442 project,
443 tag,
444 }) => {
445 assert_eq!(project, "owner/repo");
446 assert_eq!(tag, Some("v1.0+build".to_string()));
447 }
448 _ => panic!("Expected percent-encoded tag to be decoded"),
449 }
450 }
451}