fix(updater): fall back to official releases when GitHub API is limited
This commit is contained in:
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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "공식 릴리스 페이지에서 지원되는 안정 버전을 확인할 수 없습니다. 나중에 다시 시도하거나 릴리스 페이지를 여세요."
|
||||
}
|
||||
}
|
||||
@@ -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": "重新校验安装包",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user