1use anyhow::{Result, anyhow};
4use flate2::read::GzDecoder;
5use reqwest::blocking::Client;
6use sha2::{Digest, Sha256};
7use std::io::Cursor;
8use std::path::PathBuf;
9use tar::Archive;
10use tempfile::TempDir;
11
12pub struct GitHubRepo {
14 pub owner: String,
15 pub name: String,
16}
17
18impl GitHubRepo {
19 pub fn from_url(url: &str) -> Result<Self> {
22 if !url.contains("github.com") {
23 return Err(anyhow!("URL is not a GitHub URL: {url}"));
24 }
25 let url = url.trim_end_matches('/').trim_end_matches(".git");
26 let parts: Vec<&str> = url.rsplitn(3, '/').collect();
27 if parts.len() < 2 {
28 return Err(anyhow!("Cannot parse GitHub URL: {url}"));
29 }
30 Ok(Self { name: parts[0].to_string(), owner: parts[1].to_string() })
31 }
32}
33
34pub fn github_archive_url(repo: &GitHubRepo, version: &str, tag_prefix: &str) -> String {
36 format!("https://github.com/{}/{}/archive/{tag_prefix}{version}.tar.gz", repo.owner, repo.name)
37}
38
39pub fn tag_to_jinja_template(tag: &str, version: &str) -> String {
42 if tag.contains(version) { tag.replace(version, "{{ version }}") } else { tag.to_string() }
43}
44
45pub struct ResolvedGitHubSource {
47 pub url_template: String,
49 pub sha256: String,
51 pub tag: String,
53 pub extracted: Option<ExtractedSource>,
55}
56
57pub fn resolve_github_source(
63 client: &Client,
64 repo: &GitHubRepo,
65 version: &str,
66 tag_override: Option<&str>,
67 use_refs_tags: bool,
68) -> Result<ResolvedGitHubSource> {
69 if let Some(tag) = tag_override {
70 return resolve_with_tag(client, repo, version, tag, use_refs_tags);
71 }
72
73 for tag in &[format!("v{version}"), version.to_string()] {
76 let result = resolve_with_tag(client, repo, version, tag, use_refs_tags);
77 if result.is_ok() {
78 return result;
79 }
80 }
81
82 Err(anyhow!("Could not download GitHub archive for {}/{} v{}", repo.owner, repo.name, version))
83}
84
85fn resolve_with_tag(
90 client: &Client,
91 repo: &GitHubRepo,
92 version: &str,
93 tag: &str,
94 use_refs_tags: bool,
95) -> Result<ResolvedGitHubSource> {
96 let url = format!("https://github.com/{}/{}/archive/{tag}.tar.gz", repo.owner, repo.name);
97 let bytes = match client.get(&url).send() {
98 Ok(resp) if resp.status().is_success() => resp.bytes()?,
99 _ => {
100 log::info!(
102 "Public archive URL returned error; trying API tarball endpoint for {}/{}",
103 repo.owner,
104 repo.name
105 );
106 let api_url =
107 format!("https://api.github.com/repos/{}/{}/tarball/{tag}", repo.owner, repo.name);
108 let resp =
109 client.get(&api_url).header("Accept", "application/vnd.github+json").send()?;
110 if !resp.status().is_success() {
111 return Err(anyhow!(
112 "Could not download GitHub archive for {}/{} at tag {tag}: HTTP {}",
113 repo.owner,
114 repo.name,
115 resp.status()
116 ));
117 }
118 resp.bytes()?
119 }
120 };
121 let hash = sha256_hex(&bytes);
122
123 let extracted = match extract_tar_gz(&bytes) {
125 Ok(e) => Some(e),
126 Err(e) => {
127 log::warn!("Failed to extract GitHub archive for {}/{tag}: {e}", repo.name);
128 None
129 }
130 };
131
132 let archive_base = if use_refs_tags { "archive/refs/tags" } else { "archive" };
133
134 if !tag.contains(version) {
136 log::warn!(
137 "Tag '{tag}' does not contain version '{version}'; \
138 URL template will use the literal tag and won't auto-update."
139 );
140 }
141 let template_tag = tag_to_jinja_template(tag, version);
142 let template = format!(
143 "https://github.com/{}/{}/{archive_base}/{template_tag}.tar.gz",
144 repo.owner, repo.name
145 );
146
147 Ok(ResolvedGitHubSource {
148 url_template: template,
149 sha256: hash,
150 tag: tag.to_string(),
151 extracted,
152 })
153}
154
155fn sha256_hex(bytes: &[u8]) -> String {
157 let mut hasher = Sha256::new();
158 hasher.update(bytes);
159 format!("{:x}", hasher.finalize())
160}
161
162pub fn fetch_github_raw(
164 client: &Client,
165 repo: &GitHubRepo,
166 tag: &str,
167 path: &str,
168) -> Result<String> {
169 let url =
170 format!("https://raw.githubusercontent.com/{}/{}/{tag}/{path}", repo.owner, repo.name);
171 let resp = client.get(&url).send()?;
172 if !resp.status().is_success() {
173 return Err(anyhow!("Failed to fetch {path} from {}/{} at {tag}", repo.owner, repo.name));
174 }
175 Ok(resp.text()?)
176}
177
178pub fn fetch_github_tree(client: &Client, repo: &GitHubRepo, tag: &str) -> Result<Vec<String>> {
181 let url = format!(
182 "https://api.github.com/repos/{}/{}/git/trees/{tag}?recursive=1",
183 repo.owner, repo.name
184 );
185 let resp = client.get(&url).header("Accept", "application/vnd.github+json").send()?;
186 if !resp.status().is_success() {
187 return Err(anyhow!(
188 "Failed to fetch tree for {}/{} at {tag}: HTTP {}",
189 repo.owner,
190 repo.name,
191 resp.status()
192 ));
193 }
194 let body: serde_json::Value = resp.json()?;
195 let paths = body
196 .get("tree")
197 .and_then(|t| t.as_array())
198 .map(|arr| {
199 arr.iter()
200 .filter_map(|entry| entry.get("path").and_then(|p| p.as_str()).map(String::from))
201 .collect()
202 })
203 .unwrap_or_default();
204 Ok(paths)
205}
206
207pub fn is_valid_sha256(hash: &str) -> bool {
209 hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())
210}
211
212pub fn latest_github_release(client: &Client, repo: &GitHubRepo) -> Result<String> {
217 let mut candidates: Vec<String> = Vec::new();
218
219 let releases_url =
221 format!("https://api.github.com/repos/{}/{}/releases?per_page=10", repo.owner, repo.name);
222 if let Ok(resp) =
223 client.get(&releases_url).header("Accept", "application/vnd.github+json").send()
224 {
225 if resp.status().is_success() {
226 if let Ok(body) = resp.json::<serde_json::Value>() {
227 if let Some(releases) = body.as_array() {
228 for release in releases {
229 let is_pre =
231 release.get("prerelease").and_then(|v| v.as_bool()).unwrap_or(false);
232 let is_draft =
233 release.get("draft").and_then(|v| v.as_bool()).unwrap_or(false);
234 if is_pre || is_draft {
235 continue;
236 }
237 if let Some(tag) = release.get("tag_name").and_then(|t| t.as_str()) {
238 if looks_like_version_tag(tag) && !is_prerelease_tag(tag) {
239 candidates.push(tag.to_string());
240 break;
241 }
242 log::debug!(
243 "Skipping non-version release tag '{tag}' for {}/{}",
244 repo.owner,
245 repo.name
246 );
247 }
248 }
249 }
250 }
251 }
252 }
253
254 let tags_url =
256 format!("https://api.github.com/repos/{}/{}/tags?per_page=10", repo.owner, repo.name);
257 if let Ok(resp) = client.get(&tags_url).header("Accept", "application/vnd.github+json").send() {
258 if resp.status().is_success() {
259 if let Ok(body) = resp.json::<serde_json::Value>() {
260 if let Some(tags) = body.as_array() {
261 for tag_obj in tags {
262 if let Some(tag) = tag_obj.get("name").and_then(|n| n.as_str()) {
263 if looks_like_version_tag(tag) && !is_prerelease_tag(tag) {
264 candidates.push(tag.to_string());
265 break;
266 }
267 log::debug!(
268 "Skipping non-stable tag '{tag}' for {}/{}",
269 repo.owner,
270 repo.name
271 );
272 }
273 }
274 }
275 }
276 }
277 }
278
279 if candidates.is_empty() {
280 return Err(anyhow!(
281 "No version-like tags found for {}/{}. \
282 Use --tag to specify the release tag manually.",
283 repo.owner,
284 repo.name
285 ));
286 }
287
288 if candidates.len() > 1 {
290 let v0 = tag_to_version(&candidates[0]);
291 let v1 = tag_to_version(&candidates[1]);
292 if v0 != v1 {
293 log::debug!(
294 "Release tag '{}' vs tags API '{}' — comparing versions",
295 candidates[0],
296 candidates[1]
297 );
298 if compare_version_strings(&v1, &v0) {
300 log::info!(
301 "Tags API has newer version '{}' than latest release '{}'; using tags",
302 candidates[1],
303 candidates[0]
304 );
305 return Ok(candidates.swap_remove(1));
306 }
307 }
308 }
309
310 Ok(candidates.swap_remove(0))
311}
312
313fn compare_version_strings(a: &str, b: &str) -> bool {
316 let parse_segments = |s: &str| -> Vec<u64> {
317 s.split(['.', '-']).filter_map(|seg| seg.parse::<u64>().ok()).collect()
318 };
319 let sa = parse_segments(a);
320 let sb = parse_segments(b);
321 sa > sb
322}
323
324pub fn tag_to_version(tag: &str) -> String {
327 if let Some(rest) = tag.strip_prefix("v.") {
329 return rest.to_string();
330 }
331 tag.strip_prefix('v').unwrap_or(tag).to_string()
332}
333
334pub fn looks_like_version_tag(tag: &str) -> bool {
338 let version_part = tag.strip_prefix("v.").or_else(|| tag.strip_prefix('v')).unwrap_or(tag);
340 version_part.starts_with(|c: char| c.is_ascii_digit())
342}
343
344pub fn is_prerelease_tag(tag: &str) -> bool {
347 let lower = tag.to_lowercase();
348 ["-alpha", "-beta", "-rc", "-dev", "-pre", ".alpha", ".beta", ".rc"]
349 .iter()
350 .any(|suffix| lower.contains(suffix))
351}
352
353pub fn crates_io_url(base_url: &str, dl_path: &str) -> String {
355 format!("{base_url}{dl_path}")
356}
357
358pub fn compute_sha256(client: &Client, url: &str) -> Result<(Vec<u8>, String)> {
360 let response = client.get(url).send()?;
361 let bytes = response.bytes()?;
362 let hash = sha256_hex(&bytes);
363 Ok((bytes.to_vec(), hash))
364}
365
366pub struct ExtractedSource {
371 #[allow(dead_code)]
372 tmp: TempDir,
373 pub root: PathBuf,
375}
376
377pub fn extract_tar_gz(bytes: &[u8]) -> Result<ExtractedSource> {
383 let tmp = tempfile::Builder::new().prefix("redskull-src-").tempdir()?;
384 let mut archive = Archive::new(GzDecoder::new(Cursor::new(bytes)));
385 archive.unpack(tmp.path())?;
386
387 let mut entries: Vec<PathBuf> =
389 std::fs::read_dir(tmp.path())?.filter_map(|e| e.ok().map(|e| e.path())).collect();
390 entries.sort();
391 let root = if entries.len() == 1 && entries[0].is_dir() {
392 entries.remove(0)
393 } else {
394 tmp.path().to_path_buf()
395 };
396 Ok(ExtractedSource { tmp, root })
397}
398
399pub fn fetch_and_extract(client: &Client, url: &str) -> Result<(String, ExtractedSource)> {
402 let resp = client.get(url).send()?;
403 if !resp.status().is_success() {
404 return Err(anyhow!("Failed to download {url}: HTTP {}", resp.status()));
405 }
406 let bytes = resp.bytes()?;
407 let hash = sha256_hex(&bytes);
408 let extracted = extract_tar_gz(&bytes)?;
409 Ok((hash, extracted))
410}