use anyhow::{Result, anyhow};
use flate2::read::GzDecoder;
use reqwest::blocking::Client;
use sha2::{Digest, Sha256};
use std::io::Cursor;
use std::path::PathBuf;
use tar::Archive;
use tempfile::TempDir;
pub struct GitHubRepo {
pub owner: String,
pub name: String,
}
impl GitHubRepo {
pub fn from_url(url: &str) -> Result<Self> {
if !url.contains("github.com") {
return Err(anyhow!("URL is not a GitHub URL: {url}"));
}
let url = url.trim_end_matches('/').trim_end_matches(".git");
let parts: Vec<&str> = url.rsplitn(3, '/').collect();
if parts.len() < 2 {
return Err(anyhow!("Cannot parse GitHub URL: {url}"));
}
Ok(Self { name: parts[0].to_string(), owner: parts[1].to_string() })
}
}
pub fn github_archive_url(repo: &GitHubRepo, version: &str, tag_prefix: &str) -> String {
format!("https://github.com/{}/{}/archive/{tag_prefix}{version}.tar.gz", repo.owner, repo.name)
}
pub fn tag_to_jinja_template(tag: &str, version: &str) -> String {
if tag.contains(version) { tag.replace(version, "{{ version }}") } else { tag.to_string() }
}
pub struct ResolvedGitHubSource {
pub url_template: String,
pub sha256: String,
pub tag: String,
pub extracted: Option<ExtractedSource>,
}
pub fn resolve_github_source(
client: &Client,
repo: &GitHubRepo,
version: &str,
tag_override: Option<&str>,
use_refs_tags: bool,
) -> Result<ResolvedGitHubSource> {
if let Some(tag) = tag_override {
return resolve_with_tag(client, repo, version, tag, use_refs_tags);
}
for tag in &[format!("v{version}"), version.to_string()] {
let result = resolve_with_tag(client, repo, version, tag, use_refs_tags);
if result.is_ok() {
return result;
}
}
Err(anyhow!("Could not download GitHub archive for {}/{} v{}", repo.owner, repo.name, version))
}
fn resolve_with_tag(
client: &Client,
repo: &GitHubRepo,
version: &str,
tag: &str,
use_refs_tags: bool,
) -> Result<ResolvedGitHubSource> {
let url = format!("https://github.com/{}/{}/archive/{tag}.tar.gz", repo.owner, repo.name);
let bytes = match client.get(&url).send() {
Ok(resp) if resp.status().is_success() => resp.bytes()?,
_ => {
log::info!(
"Public archive URL returned error; trying API tarball endpoint for {}/{}",
repo.owner,
repo.name
);
let api_url =
format!("https://api.github.com/repos/{}/{}/tarball/{tag}", repo.owner, repo.name);
let resp =
client.get(&api_url).header("Accept", "application/vnd.github+json").send()?;
if !resp.status().is_success() {
return Err(anyhow!(
"Could not download GitHub archive for {}/{} at tag {tag}: HTTP {}",
repo.owner,
repo.name,
resp.status()
));
}
resp.bytes()?
}
};
let hash = sha256_hex(&bytes);
let extracted = match extract_tar_gz(&bytes) {
Ok(e) => Some(e),
Err(e) => {
log::warn!("Failed to extract GitHub archive for {}/{tag}: {e}", repo.name);
None
}
};
let archive_base = if use_refs_tags { "archive/refs/tags" } else { "archive" };
if !tag.contains(version) {
log::warn!(
"Tag '{tag}' does not contain version '{version}'; \
URL template will use the literal tag and won't auto-update."
);
}
let template_tag = tag_to_jinja_template(tag, version);
let template = format!(
"https://github.com/{}/{}/{archive_base}/{template_tag}.tar.gz",
repo.owner, repo.name
);
Ok(ResolvedGitHubSource {
url_template: template,
sha256: hash,
tag: tag.to_string(),
extracted,
})
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
pub fn fetch_github_raw(
client: &Client,
repo: &GitHubRepo,
tag: &str,
path: &str,
) -> Result<String> {
let url =
format!("https://raw.githubusercontent.com/{}/{}/{tag}/{path}", repo.owner, repo.name);
let resp = client.get(&url).send()?;
if !resp.status().is_success() {
return Err(anyhow!("Failed to fetch {path} from {}/{} at {tag}", repo.owner, repo.name));
}
Ok(resp.text()?)
}
pub fn fetch_github_tree(client: &Client, repo: &GitHubRepo, tag: &str) -> Result<Vec<String>> {
let url = format!(
"https://api.github.com/repos/{}/{}/git/trees/{tag}?recursive=1",
repo.owner, repo.name
);
let resp = client.get(&url).header("Accept", "application/vnd.github+json").send()?;
if !resp.status().is_success() {
return Err(anyhow!(
"Failed to fetch tree for {}/{} at {tag}: HTTP {}",
repo.owner,
repo.name,
resp.status()
));
}
let body: serde_json::Value = resp.json()?;
let paths = body
.get("tree")
.and_then(|t| t.as_array())
.map(|arr| {
arr.iter()
.filter_map(|entry| entry.get("path").and_then(|p| p.as_str()).map(String::from))
.collect()
})
.unwrap_or_default();
Ok(paths)
}
pub fn is_valid_sha256(hash: &str) -> bool {
hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())
}
pub fn latest_github_release(client: &Client, repo: &GitHubRepo) -> Result<String> {
let mut candidates: Vec<String> = Vec::new();
let releases_url =
format!("https://api.github.com/repos/{}/{}/releases?per_page=10", repo.owner, repo.name);
if let Ok(resp) =
client.get(&releases_url).header("Accept", "application/vnd.github+json").send()
{
if resp.status().is_success() {
if let Ok(body) = resp.json::<serde_json::Value>() {
if let Some(releases) = body.as_array() {
for release in releases {
let is_pre =
release.get("prerelease").and_then(|v| v.as_bool()).unwrap_or(false);
let is_draft =
release.get("draft").and_then(|v| v.as_bool()).unwrap_or(false);
if is_pre || is_draft {
continue;
}
if let Some(tag) = release.get("tag_name").and_then(|t| t.as_str()) {
if looks_like_version_tag(tag) && !is_prerelease_tag(tag) {
candidates.push(tag.to_string());
break;
}
log::debug!(
"Skipping non-version release tag '{tag}' for {}/{}",
repo.owner,
repo.name
);
}
}
}
}
}
}
let tags_url =
format!("https://api.github.com/repos/{}/{}/tags?per_page=10", repo.owner, repo.name);
if let Ok(resp) = client.get(&tags_url).header("Accept", "application/vnd.github+json").send() {
if resp.status().is_success() {
if let Ok(body) = resp.json::<serde_json::Value>() {
if let Some(tags) = body.as_array() {
for tag_obj in tags {
if let Some(tag) = tag_obj.get("name").and_then(|n| n.as_str()) {
if looks_like_version_tag(tag) && !is_prerelease_tag(tag) {
candidates.push(tag.to_string());
break;
}
log::debug!(
"Skipping non-stable tag '{tag}' for {}/{}",
repo.owner,
repo.name
);
}
}
}
}
}
}
if candidates.is_empty() {
return Err(anyhow!(
"No version-like tags found for {}/{}. \
Use --tag to specify the release tag manually.",
repo.owner,
repo.name
));
}
if candidates.len() > 1 {
let v0 = tag_to_version(&candidates[0]);
let v1 = tag_to_version(&candidates[1]);
if v0 != v1 {
log::debug!(
"Release tag '{}' vs tags API '{}' — comparing versions",
candidates[0],
candidates[1]
);
if compare_version_strings(&v1, &v0) {
log::info!(
"Tags API has newer version '{}' than latest release '{}'; using tags",
candidates[1],
candidates[0]
);
return Ok(candidates.swap_remove(1));
}
}
}
Ok(candidates.swap_remove(0))
}
fn compare_version_strings(a: &str, b: &str) -> bool {
let parse_segments = |s: &str| -> Vec<u64> {
s.split(['.', '-']).filter_map(|seg| seg.parse::<u64>().ok()).collect()
};
let sa = parse_segments(a);
let sb = parse_segments(b);
sa > sb
}
pub fn tag_to_version(tag: &str) -> String {
if let Some(rest) = tag.strip_prefix("v.") {
return rest.to_string();
}
tag.strip_prefix('v').unwrap_or(tag).to_string()
}
pub fn looks_like_version_tag(tag: &str) -> bool {
let version_part = tag.strip_prefix("v.").or_else(|| tag.strip_prefix('v')).unwrap_or(tag);
version_part.starts_with(|c: char| c.is_ascii_digit())
}
pub fn is_prerelease_tag(tag: &str) -> bool {
let lower = tag.to_lowercase();
["-alpha", "-beta", "-rc", "-dev", "-pre", ".alpha", ".beta", ".rc"]
.iter()
.any(|suffix| lower.contains(suffix))
}
pub fn crates_io_url(base_url: &str, dl_path: &str) -> String {
format!("{base_url}{dl_path}")
}
pub fn compute_sha256(client: &Client, url: &str) -> Result<(Vec<u8>, String)> {
let response = client.get(url).send()?;
let bytes = response.bytes()?;
let hash = sha256_hex(&bytes);
Ok((bytes.to_vec(), hash))
}
pub struct ExtractedSource {
#[allow(dead_code)]
tmp: TempDir,
pub root: PathBuf,
}
pub fn extract_tar_gz(bytes: &[u8]) -> Result<ExtractedSource> {
let tmp = tempfile::Builder::new().prefix("redskull-src-").tempdir()?;
let mut archive = Archive::new(GzDecoder::new(Cursor::new(bytes)));
archive.unpack(tmp.path())?;
let mut entries: Vec<PathBuf> =
std::fs::read_dir(tmp.path())?.filter_map(|e| e.ok().map(|e| e.path())).collect();
entries.sort();
let root = if entries.len() == 1 && entries[0].is_dir() {
entries.remove(0)
} else {
tmp.path().to_path_buf()
};
Ok(ExtractedSource { tmp, root })
}
pub fn fetch_and_extract(client: &Client, url: &str) -> Result<(String, ExtractedSource)> {
let resp = client.get(url).send()?;
if !resp.status().is_success() {
return Err(anyhow!("Failed to download {url}: HTTP {}", resp.status()));
}
let bytes = resp.bytes()?;
let hash = sha256_hex(&bytes);
let extracted = extract_tar_gz(&bytes)?;
Ok((hash, extracted))
}