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> {
|
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 url = format!("https://api.github.com/repos/{repo}/releases/latest");
|
||||||
let mut req = ureq::get(&url).set("User-Agent", USER_AGENT);
|
let mut req = ureq::get(&url).set("User-Agent", USER_AGENT);
|
||||||
if let Some(auth) = github_auth_header() {
|
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")
|
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 struct PickedAsset {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
@@ -519,6 +615,24 @@ mod tests {
|
|||||||
assert!(validate_url("javascript:alert(1)").is_err());
|
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]
|
#[test]
|
||||||
fn sha256_verification() {
|
fn sha256_verification() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user