v0.2.14 : fallback web GitHub pour les releases - quand l API est rate-limitee, le tag vient de la redirection /releases/latest et les assets de expanded_assets (aucune limite) - jcode et zeroclaw installables sans GITHUB_TOKEN
This commit is contained in:
+114
@@ -150,6 +150,18 @@ fn github_auth_header() -> Option<String> {
|
||||
}
|
||||
|
||||
pub fn latest_release(repo: &str) -> Result<GhRelease> {
|
||||
match latest_release_api(repo) {
|
||||
Ok(release) => Ok(release),
|
||||
Err(e) if format!("{e:#}").contains("rate limit") => {
|
||||
// The API is rate limited (60 unauthenticated requests/hour):
|
||||
// fall back to the GitHub web pages, which are not limited.
|
||||
latest_release_web(repo)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn latest_release_api(repo: &str) -> Result<GhRelease> {
|
||||
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
|
||||
let mut req = ureq::get(&url).set("User-Agent", USER_AGENT);
|
||||
if let Some(auth) = github_auth_header() {
|
||||
@@ -170,6 +182,90 @@ pub fn latest_release(repo: &str) -> Result<GhRelease> {
|
||||
serde_json::from_str::<GhRelease>(&text).context("parsing GitHub API response")
|
||||
}
|
||||
|
||||
/// Discover the latest release without the API: the /releases/latest page
|
||||
/// redirects to the tagged page (tag from the final URL), then the
|
||||
/// expanded_assets endpoint lists every asset. These web pages are not
|
||||
/// subject to the API rate limit.
|
||||
fn latest_release_web(repo: &str) -> Result<GhRelease> {
|
||||
let latest = format!("https://github.com/{repo}/releases/latest");
|
||||
let resp = ureq::get(&latest)
|
||||
.set("User-Agent", USER_AGENT)
|
||||
.call()
|
||||
.map_err(|e| anyhow!("cannot reach GitHub releases page for '{repo}': {e}"))?;
|
||||
let final_url = resp.get_url().to_string();
|
||||
let tag = final_url
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.map(|s| s.to_string())
|
||||
.filter(|s| !s.is_empty() && final_url.contains("/releases/tag/"))
|
||||
.ok_or_else(|| {
|
||||
anyhow!("no GitHub releases found for repository '{repo}' (no tagged release page)")
|
||||
})?;
|
||||
|
||||
let assets_url = format!("https://github.com/{repo}/releases/expanded_assets/{tag}");
|
||||
let resp = ureq::get(&assets_url)
|
||||
.set("User-Agent", USER_AGENT)
|
||||
.call()
|
||||
.map_err(|e| anyhow!("cannot list GitHub release assets for '{repo}': {e}"))?;
|
||||
let html = resp
|
||||
.into_string()
|
||||
.context("reading GitHub expanded_assets response")?;
|
||||
let mut assets = parse_expanded_assets(&html, repo, &tag);
|
||||
if assets.is_empty() {
|
||||
anyhow::bail!(
|
||||
"GitHub release '{repo}@{tag}' has no downloadable assets (web fallback)"
|
||||
);
|
||||
}
|
||||
assets.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
assets.dedup_by(|a, b| a.name == b.name);
|
||||
Ok(GhRelease {
|
||||
tag_name: tag,
|
||||
assets,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract the asset links from a GitHub expanded_assets HTML fragment.
|
||||
fn parse_expanded_assets(html: &str, repo: &str, tag: &str) -> Vec<GhAsset> {
|
||||
let marker = format!("/{repo}/releases/download/");
|
||||
let mut assets = Vec::new();
|
||||
let mut rest = html;
|
||||
while let Some(pos) = rest.find(&marker) {
|
||||
let start = pos + marker.len();
|
||||
let end = rest[start..]
|
||||
.find(['"', '\'', '<'])
|
||||
.map(|i| start + i)
|
||||
.unwrap_or(rest.len());
|
||||
let raw = &rest[start..end];
|
||||
// The href reads "download/<tag>/<asset-name>": keep the name only.
|
||||
let raw = raw.strip_prefix(&format!("{tag}/")).unwrap_or(raw);
|
||||
// GitHub sometimes hides zero-width characters inside hrefs.
|
||||
let name = raw.replace('\u{200b}', "");
|
||||
let name = decode_html_entities(&name);
|
||||
if !name.is_empty() {
|
||||
assets.push(GhAsset {
|
||||
name: name.clone(),
|
||||
browser_download_url: format!(
|
||||
"https://github.com/{repo}/releases/download/{tag}/{name}"
|
||||
),
|
||||
size: 0,
|
||||
});
|
||||
}
|
||||
rest = &rest[end..];
|
||||
}
|
||||
assets
|
||||
}
|
||||
|
||||
fn decode_html_entities(s: &str) -> String {
|
||||
s.replace("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(""", "\"")
|
||||
.replace(" ", " ")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(" ", " ")
|
||||
}
|
||||
|
||||
pub struct PickedAsset {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
@@ -519,6 +615,24 @@ mod tests {
|
||||
assert!(validate_url("javascript:alert(1)").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_expanded_assets_fragment() {
|
||||
let html = r#"<div><a href="/owner/repo/releases/download/v1.2.3/tool-windows-x86_64.exe">x</a>
|
||||
<a href="/owner/repo/releases/download/v1.2.3/tool-windows-x86_64 copy.zip">y</a>
|
||||
<span>ignore me</span>
|
||||
<a href="/owner/repo/releases/download/v1.2.3/SHA256SUMS">z</a></div>"#;
|
||||
let assets = parse_expanded_assets(html, "owner/repo", "v1.2.3");
|
||||
let names: Vec<&str> = assets.iter().map(|a| a.name.as_str()).collect();
|
||||
assert!(names.contains(&"tool-windows-x86_64.exe"), "{names:?}");
|
||||
assert!(names.contains(&"tool-windows-x86_64 copy.zip"), "{names:?}");
|
||||
assert!(names.contains(&"SHA256SUMS"), "{names:?}");
|
||||
assert_eq!(assets.len(), 3);
|
||||
assert_eq!(
|
||||
assets[0].browser_download_url,
|
||||
"https://github.com/owner/repo/releases/download/v1.2.3/tool-windows-x86_64.exe"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_verification() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user