Skip to main content

redskull_lib/
source.rs

1//! Source URL generation and sha256 computation.
2
3use 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
12/// Parsed GitHub repository info.
13pub struct GitHubRepo {
14    pub owner: String,
15    pub name: String,
16}
17
18impl GitHubRepo {
19    /// Parse owner/name from a GitHub URL.
20    /// Handles trailing slashes, .git suffix, and various URL formats.
21    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
34/// Construct a GitHub archive URL for a given tag/version with a tag prefix.
35pub 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
39/// Replace the version portion of a tag with the jinja `{{ version }}` placeholder.
40/// If the tag does not contain the version, returns the literal tag.
41pub 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
45/// Resolved GitHub source info.
46pub struct ResolvedGitHubSource {
47    /// URL template with `{{ version }}` jinja placeholder.
48    pub url_template: String,
49    /// SHA256 hash of the archive.
50    pub sha256: String,
51    /// The resolved tag (e.g., "v0.3.1" or "0.3.1").
52    pub tag: String,
53    /// Extracted source tree. Cleaned up when dropped.
54    pub extracted: Option<ExtractedSource>,
55}
56
57/// Try to resolve the correct GitHub archive URL and compute its SHA256.
58/// When `tag_override` is provided, it is used directly without tag prefix detection.
59/// Otherwise, tries `v`-prefixed tag first, then bare version.
60/// When `use_refs_tags` is true, the URL template uses `/archive/refs/tags/` instead of
61/// `/archive/`.
62pub 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    // Try v-prefixed tag first, then bare version
74    // Each attempts the public archive URL, then falls back to API tarball
75    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
85/// Resolve a GitHub source using a specific tag.
86/// Tries the public archive URL first, then falls back to the API tarball endpoint
87/// (which works for private repos when GITHUB_TOKEN is set).
88/// Downloads the archive, computes the sha256, and extracts the source tree.
89fn 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            // Fall back to API tarball endpoint (works with auth for private repos)
101            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    // Extract so callers can inspect Cargo.lock and the rest of the source tree.
124    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    // Build URL template: replace the version portion of the tag with {{ version }}
135    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
155/// Compute SHA256 hex digest from bytes.
156fn sha256_hex(bytes: &[u8]) -> String {
157    let mut hasher = Sha256::new();
158    hasher.update(bytes);
159    format!("{:x}", hasher.finalize())
160}
161
162/// Fetch a raw file from a GitHub repo at a given tag.
163pub 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
178/// Fetch the file listing of a GitHub repo at a given tag using the Trees API.
179/// Returns a list of file paths relative to the repo root.
180pub 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
207/// Returns true if the given string looks like a valid SHA256 hex digest.
208pub fn is_valid_sha256(hash: &str) -> bool {
209    hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit())
210}
211
212/// Fetch the latest release tag from a GitHub repo.
213/// Tries both the releases API and tags API, then picks the best version-like tag.
214/// Skips non-version tags like "latest", "nightly", etc.
215/// Returns the tag name (e.g., "v1.2.3" or "1.2.3").
216pub fn latest_github_release(client: &Client, repo: &GitHubRepo) -> Result<String> {
217    let mut candidates: Vec<String> = Vec::new();
218
219    // Try releases API — check up to 10 recent releases for a stable version-like tag
220    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                        // Skip drafts and prereleases
230                        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    // Try tags API — get the most recent stable version-like tag
255    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 we have candidates from both sources, pick the higher version
289    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            // Compare as semver-ish: split on dots, compare numerically
299            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
313/// Compare two version strings, returning true if `a` is greater than `b`.
314/// Splits on `.` and `-`, compares segments numerically where possible.
315fn 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
324/// Strip a version prefix (`v` or `v.`) from a tag to get a bare version string.
325/// Handles `v1.2.3`, `v.1.2.3`, and bare `1.2.3` patterns.
326pub fn tag_to_version(tag: &str) -> String {
327    // Strip `v.` before `v` to handle `v.1.2.3` (e.g., alejandrogzi tools)
328    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
334/// Returns true if the given string looks like a version tag.
335/// Matches patterns like `v1.2.3`, `1.2.3`, `v.1.2.3`, `tool-v1.2.3`, etc.
336/// Returns false for tags like `latest`, `nightly`, `stable`.
337pub fn looks_like_version_tag(tag: &str) -> bool {
338    // Strip known prefixes to find the version portion
339    let version_part = tag.strip_prefix("v.").or_else(|| tag.strip_prefix('v')).unwrap_or(tag);
340    // A version-like string starts with a digit
341    version_part.starts_with(|c: char| c.is_ascii_digit())
342}
343
344/// Returns true if the tag looks like a pre-release version.
345/// Detects common pre-release suffixes: -alpha, -beta, -rc, -dev, -pre.
346pub 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
353/// Construct a crates.io download URL.
354pub fn crates_io_url(base_url: &str, dl_path: &str) -> String {
355    format!("{base_url}{dl_path}")
356}
357
358/// Download a URL and compute its sha256 hex digest.
359pub 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
366/// An extracted crate source tree in a temporary directory.
367///
368/// Owns a `TempDir` that is cleaned up on drop. `root` is the actual crate root
369/// (the single top-level directory inside the archive, where `Cargo.toml` lives).
370pub struct ExtractedSource {
371    #[allow(dead_code)]
372    tmp: TempDir,
373    /// The path to the crate root inside the tempdir.
374    pub root: PathBuf,
375}
376
377/// Extract a gzipped tarball (bytes) into a new temp directory and return the
378/// path to the single top-level directory inside it (the "crate root").
379///
380/// Most tarballs from crates.io and GitHub have exactly one top-level directory;
381/// if there are multiple or none, the tempdir root is returned.
382pub 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    // Find the single top-level directory (typical for github/crates.io archives).
388    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
399/// Download a tarball, compute its sha256, and extract it to a temp directory.
400/// Returns the hash and the extracted source tree.
401pub 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}