fix(updater): fall back to official releases when GitHub API is limited

This commit is contained in:
YinBuLiao committed 2026-09-23 16:01:37 +08:00
1 parent 0110c7c139
commit 19ed6322ca
6 files changed
+274 -6

No files matched your search

@@ -0,0 +1,69 @@
# Public release discovery when GitHub REST access is limited
## Status
Proposed for review; implemented and verified on macOS arm64.
## Context
A macOS user received HTTP 403 when checking for updates. Startup and manual
checks use the same unauthenticated GitHub REST endpoint, so shared egress IP
quota exhaustion can disable both even when the release website is reachable.
## Evidence
On 2026-09-23, the public REST endpoint returned HTTP 403 with `API rate limit
exceeded`. The official `/releases/latest` website returned HTTP 200 after
redirecting to `/Kuddev/pebrel/releases/tag/v1.9.0`. No account credentials were
needed for the website request. This is separate from macOS installer support.
## Decision
Only REST HTTP 403/429 invokes a second, bounded request to the official latest
release page. Resolve its proxy independently for github.com. The HTTP client's
final URI must name this repository, use HTTPS, and contain an exact numeric
major.minor.patch tag (optional v/V prefix). Do not parse website markup.
The fallback supplies version discovery only: it has no verified asset metadata
and returns no installer. Normal API responses retain existing installer and
SHA-256 handling. Both automatic and manual checks use this shared path.
Local update rehearsals never fall back to the public network.
## Rejected alternatives
- Embedding a GitHub token: introduces credentials and distribution risk for a
public read-only operation.
- Treating an API failure as up to date: hides real failures and misses upgrades.
- Guessing package URLs or checksums from a tag: discovery does not authorize an
installer. Scraping HTML would add a fragile metadata parser.
- Implementing a macOS installation transaction in this change: the reported
failure is version discovery, not installation.
## Consequences
A limited API may add at most one ten-second website request, with at most five
redirects. If neither query succeeds, manual checking reports an error. A newer
fallback version opens the official Releases page for manual download; no
unverified package can be auto-executed. Unsupported future tag formats fail
explicitly rather than producing a guessed version.
## Validation
16 update-check regressions and 16 proxy regressions passed on macOS. Coverage
includes 403/429, ordinary API success, malformed responses, fallback failure,
redirect transport and disallowed hosts/tags. An explicit live test successfully
queried the production checker and the public fallback, both returning 1.9.0.
Independent i18n contracts passed, including zero-allocation lookup. A native
macOS test window completed the Settings check from checking to up to date
(GitHub v1.9.0), without the previous HTTP 403. No native installer transaction
or Windows/Linux runtime acceptance is claimed.
## Supersedes
None.
## Revisit when
GitHub changes the public redirect contract, release tags gain another stable
format, or a first-party metadata endpoint supplies authenticated package hashes
without requiring client credentials.
+2
View File
@@ -780,6 +780,8 @@
"too_large": "The background image exceeds the memory limit. Choose a smaller image."
},
"update": {
"check_fallback_failed": "GitHub API access is restricted (HTTP {status}), and the official Releases page could not be reached: {error}. Retry later or open the Releases page.",
"check_unrecognized_release": "The official Releases page did not identify a supported stable version. Retry later or open the Releases page.",
"installation_failed": "Update installation failed",
"installation_failed_hint": "The update did not complete. Your original session snapshot is retained. Review the error, recheck the package, then confirm installation again.",
"recheck_package": "Recheck package",
+4
View File
@@ -173,5 +173,9 @@
"copied_lines": "{lines}줄을 클립보드에 복사했습니다",
"paste_lines": "{lines}줄을 붙여넣을까요?",
"sent_to": "{target}(으)로 전송했습니다"
},
"update": {
"check_fallback_failed": "GitHub API 접근이 제한되었으며(HTTP {status}) 공식 릴리스 페이지에도 연결할 수 없습니다: {error}. 나중에 다시 시도하거나 릴리스 페이지를 여세요.",
"check_unrecognized_release": "공식 릴리스 페이지에서 지원되는 안정 버전을 확인할 수 없습니다. 나중에 다시 시도하거나 릴리스 페이지를 여세요."
}
}
+2
View File
@@ -780,6 +780,8 @@
"too_large": "背景图超出内存限制,请选择尺寸更小的图片。"
},
"update": {
"check_fallback_failed": "GitHub API 访问受限(HTTP {status}),备用发布页查询也失败了:{error}。请稍后重试或打开发布页。",
"check_unrecognized_release": "官方发布页未返回可识别的稳定版本,请稍后重试或打开发布页。",
"installation_failed": "更新安装失败",
"installation_failed_hint": "更新未完成,原会话快照仍保留。检查错误后可重新校验安装包,再确认安装。",
"recheck_package": "重新校验安装包",
+24 -6
View File
@@ -22,6 +22,8 @@ pub const RELEASES_PAGE: &str = "https://github.com/Kuddev/pebrel/releases";
const UPDATE_STATE_FILE: &str = "update_state.json";
const REMIND_LATER_SECS: u64 = 3 * 24 * 60 * 60;
mod fallback;
#[cfg(feature = "update-test-source")]
pub(crate) mod test_source;
@@ -291,18 +293,34 @@ fn fetch_latest_release() -> Result<LatestRelease, String> {
return fetch_release_with_agent(&agent, &url);
}
let agent = crate::update_proxy::agent(RELEASES_API, Duration::from_secs(10));
fetch_release_with_agent(&agent, RELEASES_API)
fetch_release_with_fallback(&agent, RELEASES_API, fallback::fetch_latest)
}
#[cfg(any(test, feature = "update-test-source"))]
fn fetch_release_with_agent(agent: &ureq::Agent, url: &str) -> Result<LatestRelease, String> {
let bytes = agent
// Explicit local rehearsals must never fall back to the public network.
fetch_release_with_fallback(agent, url, |status| Err(format!("GitHub HTTP {status}")))
}
fn fetch_release_with_fallback(
agent: &ureq::Agent,
url: &str,
on_rate_limit: impl FnOnce(u16) -> Result<LatestRelease, String>,
) -> Result<LatestRelease, String> {
let response = agent
.get(url)
.header("User-Agent", "pebrel")
.header("Accept", "application/vnd.github+json")
.call()
.and_then(|mut response| {
response.body_mut().with_config().limit(2 * 1024 * 1024).read_to_vec()
})
.call();
let mut response = match response {
Err(ureq::Error::StatusCode(status @ (403 | 429))) => return on_rate_limit(status),
other => other.map_err(|error| format!("GitHub 请求失败:{error}"))?,
};
let bytes = response
.body_mut()
.with_config()
.limit(2 * 1024 * 1024)
.read_to_vec()
.map_err(|error| format!("GitHub 请求失败:{error}"))?;
parse_latest_release(&bytes)
}
+173
View File
@@ -0,0 +1,173 @@
//! GitHub's public latest-release redirect is independent of the REST API quota.
//! It proves a version, not an installer or checksum: fallback results only offer
//! the official Releases page. Do not scrape HTML or invent download metadata.
use std::time::Duration;
use ureq::ResponseExt as _;
use super::LatestRelease;
use crate::i18n::{LanguagePreference, Message};
const LATEST_PAGE: &str = "https://github.com/Kuddev/pebrel/releases/latest";
const TAG_PREFIX: &str = "https://github.com/Kuddev/pebrel/releases/tag/";
pub(super) fn fetch_latest(status: u16) -> Result<LatestRelease, String> {
log::info!("update-check: API returned HTTP {status}; checking official latest-release page");
// Resolve proxy settings for github.com independently of api.github.com.
let agent = crate::update_proxy::agent(LATEST_PAGE, Duration::from_secs(10));
let language =
LanguagePreference::from(nebula_settings::RuntimeSettings::load().language).resolved();
let uri = redirected_uri(&agent, LATEST_PAGE).map_err(|error| {
language.format(
Message::UpdateCheckFallbackFailed,
&[("status", &status.to_string()), ("error", &error.to_string())],
)
})?;
release_from_uri(&uri)
.ok_or_else(|| language.text(Message::UpdateCheckUnrecognizedRelease).to_owned())
}
fn redirected_uri(agent: &ureq::Agent, url: &str) -> Result<String, ureq::Error> {
let response = agent
.get(url)
.header("User-Agent", "pebrel")
.header("Accept", "text/html")
.config()
.max_redirects(5)
.build()
.call()?;
// The final response URI is supplied by the HTTP client, not by untrusted
// markup. No HTML body needs to be downloaded or parsed.
Ok(response.get_uri().to_string())
}
fn release_from_uri(uri: &str) -> Option<LatestRelease> {
let tag = uri.strip_prefix(TAG_PREFIX)?;
let version = tag.strip_prefix(['v', 'V']).unwrap_or(tag);
// Official stable releases use major.minor.patch. Reject login pages,
// arbitrary tags, extra path/query/fragment components and preview tags.
let components: Vec<_> = version.split('.').collect();
if components.len() != 3
|| components.iter().any(|part| {
part.is_empty()
|| !part.bytes().all(|byte| byte.is_ascii_digit())
|| part.parse::<u64>().is_err()
|| (part.len() > 1 && part.starts_with('0'))
})
{
return None;
}
Some(LatestRelease { version: version.to_owned(), asset: None })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::update_proxy::test_support::{Server, response};
#[test]
fn rate_limited_api_falls_back_and_discovers_an_upgrade_without_install_authority() {
for status in ["403 Forbidden", "429 Too Many Requests"] {
let server = Server::start(vec![response(status, "", "API rate limit exceeded")]);
let result = super::super::fetch_release_with_fallback(
&server.agent(&[]),
"http://api.update.invalid/latest",
|code| {
assert!(matches!(code, 403 | 429));
Ok(release_from_uri(&format!("{TAG_PREFIX}v1.9.0")).unwrap())
},
)
.unwrap();
assert!(super::super::is_newer(&result.version, "1.8.2"));
assert!(!super::super::is_newer(&result.version, "1.9.0"));
assert!(result.asset.is_none(), "a version redirect grants no install authority");
assert_eq!(server.finish().len(), 1);
}
}
#[test]
fn successful_api_and_other_errors_never_call_the_fallback() {
for (status, body, succeeds) in [
("200 OK", r#"{"tag_name":"v1.9.0","assets":[]}"#, true),
("200 OK", "not JSON", false),
("404 Not Found", "missing release", false),
("500 Internal Server Error", "server error", false),
] {
let server = Server::start(vec![response(status, "", body)]);
let result = super::super::fetch_release_with_fallback(
&server.agent(&[]),
"http://api.update.invalid/latest",
|_| panic!("fallback is only for access/rate limiting"),
);
assert_eq!(result.is_ok(), succeeds);
server.finish();
}
}
#[test]
fn failed_fallback_stays_an_error_instead_of_claiming_up_to_date() {
let server = Server::start(vec![response("403 Forbidden", "", "rate limited")]);
let result = super::super::fetch_release_with_fallback(
&server.agent(&[]),
"http://api.update.invalid/latest",
|_| Err("release page unavailable".into()),
);
assert_eq!(result.unwrap_err(), "release page unavailable");
server.finish();
}
#[test]
fn redirect_transport_uses_the_final_location_without_parsing_html() {
// The local CONNECT fixture uses HTTP; production agent requires HTTPS.
let destination = "http://github.com/Kuddev/pebrel/releases/tag/v1.9.0";
let server = Server::start(vec![
response("302 Found", &format!("Location: {destination}\r\n"), ""),
response("200 OK", "Content-Type: text/html\r\n", "<html>no metadata</html>"),
]);
let uri =
redirected_uri(&server.agent(&[]), "http://github.com/Kuddev/pebrel/releases/latest")
.unwrap();
assert_eq!(uri, destination);
let requests = server.finish();
assert_eq!(requests.len(), 2);
assert!(requests[0].1.contains("/releases/latest"));
assert!(requests[1].1.contains("/releases/tag/v1.9.0"));
}
#[test]
fn only_exact_official_stable_tag_locations_are_accepted() {
for tag in ["v1.9.0", "V1.9.0", "1.9.0"] {
assert_eq!(release_from_uri(&format!("{TAG_PREFIX}{tag}")).unwrap().version, "1.9.0");
}
for uri in [
LATEST_PAGE,
"https://github.com/login",
"https://github.com/Other/pebrel/releases/tag/v99.0.0",
"http://github.com/Kuddev/pebrel/releases/tag/v1.9.0",
"https://github.com.evil.invalid/Kuddev/pebrel/releases/tag/v1.9.0",
"https://github.com/Kuddev/pebrel/releases/tag/v1.9.0?foo=bar",
"https://github.com/Kuddev/pebrel/releases/tag/v1.9.0#fragment",
"https://github.com/Kuddev/pebrel/releases/tag/v1.9.0/extra",
"https://github.com/Kuddev/pebrel/releases/tag/v1.9.0-rc1",
"https://github.com/Kuddev/pebrel/releases/tag/relay-abcd",
"https://github.com/Kuddev/pebrel/releases/tag/v1..0",
"https://github.com/Kuddev/pebrel/releases/tag/v01.9.0",
"https://github.com/Kuddev/pebrel/releases/tag/v18446744073709551616.0.0",
] {
assert!(release_from_uri(uri).is_none(), "{uri}");
}
}
#[test]
#[ignore = "contacts public GitHub; run explicitly for native network acceptance"]
fn live_update_check_survives_api_rate_limiting() {
let release = super::super::check_now().expect("live public update check");
println!(
"current={} latest={} update_available={}",
release.current, release.latest, release.update_available
);
let fallback = fetch_latest(403).expect("live official release fallback");
assert_eq!(release.latest, fallback.version);
}
}