#![cfg(feature = "github")]
use std::sync::Arc;
use std::time::Duration;
use self_update::errors::Error;
use self_update::http_client::{HeaderMap, HttpClient, HttpResponse};
use std::error::Error as StdError;
struct IoErrorClient;
impl HttpClient for IoErrorClient {
fn get(
&self,
_url: &str,
_headers: &HeaderMap,
_timeout: Option<Duration>,
) -> self_update::Result<Box<dyn HttpResponse>> {
Err(Error::Io(std::io::Error::other("simulated failure")))
}
}
fn headers(pairs: &[(&'static str, &str)]) -> HeaderMap {
let mut map = HeaderMap::new();
for (name, value) in pairs {
map.insert(*name, value.parse().expect("valid header value"));
}
map
}
fn reset_epoch(offset_secs: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock is after the unix epoch")
.as_secs() as i64;
(now + offset_secs).to_string()
}
#[test]
fn not_found_constructable_and_helpers_correct_from_outside() {
let err = Error::http_status_error(404, "https://example.com/missing");
assert!(matches!(err, Error::NotFound { .. }));
assert_eq!(err.http_status(), Some(404));
assert_eq!(err.url(), Some("https://example.com/missing"));
assert!(
err.source().is_none(),
"NotFound must not expose a chained source()"
);
let shown = err.to_string();
assert!(shown.starts_with("NotFoundError: "), "got: {shown}");
}
#[test]
fn verification_rejected_constructable_from_outside() {
let err = Error::verification_rejected("bad signature");
assert!(matches!(err, Error::VerificationRejected { .. }));
let shown = err.to_string();
assert!(shown.contains("bad signature"), "got: {shown}");
assert_eq!(err.http_status(), None);
assert_eq!(err.url(), None);
}
#[test]
fn no_release_found_constructors_from_outside() {
let plain = Error::no_release_found();
assert!(matches!(plain, Error::NoReleaseFound { .. }));
let scoped = Error::no_release_found_for_target("x86_64-unknown-linux-gnu");
let shown = scoped.to_string();
assert!(shown.contains("x86_64-unknown-linux-gnu"), "got: {shown}");
let _ = Error::no_release_found_for_target(format!("{}-msvc", "x86_64"));
}
#[test]
fn missing_asset_field_accepts_dynamic_paths_from_outside() {
let idx = 2;
let err = Error::missing_asset_field(format!("assets[{idx}].url"));
assert!(matches!(err, Error::MissingAssetField { .. }));
let shown = err.to_string();
assert!(shown.contains("assets[2].url"), "got: {shown}");
}
#[test]
fn checksum_mismatch_constructable_from_outside() {
let err = Error::checksum_mismatch("aa11", "bb22");
assert!(matches!(err, Error::ChecksumMismatch { .. }));
let shown = err.to_string();
assert!(
shown.contains("aa11") && shown.contains("bb22"),
"Display must carry both digests, got: {shown}"
);
assert_eq!(err.http_status(), None);
assert_eq!(err.url(), None);
}
#[test]
fn transport_constructor_from_outside() {
let err = Error::transport(std::io::Error::other("connection reset"));
assert!(matches!(err, Error::Transport(_)));
let src = err.source().expect("Error::transport must chain source()");
assert!(src.to_string().contains("connection reset"), "got: {src}");
let shown = err.to_string();
assert!(shown.starts_with("TransportError: "), "got: {shown}");
let err = Error::transport("proxy refused the request");
assert!(matches!(err, Error::Transport(_)));
assert!(
err.to_string().contains("proxy refused the request"),
"got: {err}"
);
}
#[test]
fn io_error_source_accessible_from_outside() {
let err = Error::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"denied",
));
let src = err.source().expect("Error::Io must have a source");
assert!(
src.to_string().contains("denied"),
"source must carry the inner io message, got: {}",
src
);
assert_eq!(err.http_status(), None);
assert_eq!(err.url(), None);
}
#[test]
fn error_enum_match_requires_wildcard_arm() {
fn classify(err: &Error) -> &'static str {
match err {
Error::NotFound { .. } => "not-found",
Error::Aborted => "aborted",
_ => "other",
}
}
assert_eq!(classify(&Error::http_status_error(404, "u")), "not-found");
assert_eq!(classify(&Error::Aborted), "aborted");
assert_eq!(
classify(&Error::Io(std::io::Error::other("x"))),
"other",
"Io and any future variants fall through to the wildcard"
);
}
#[test]
fn injected_transport_error_propagates_through_backend() {
let result = self_update::backends::github::ReleaseList::configure()
.repo_owner("o")
.repo_name("r")
.http_client(Arc::new(IoErrorClient))
.build()
.unwrap()
.fetch();
assert!(result.is_err(), "fetch must fail when the transport errors");
match result.unwrap_err() {
Error::Io(_) => {} other => panic!("expected Error::Io, got {:?}", other),
}
}
struct CountingErrorClient<F> {
calls: std::sync::atomic::AtomicUsize,
make_error: F,
}
impl<F> CountingErrorClient<F>
where
F: Fn() -> Error + Send + Sync,
{
fn new(make_error: F) -> Self {
Self {
calls: std::sync::atomic::AtomicUsize::new(0),
make_error,
}
}
fn calls(&self) -> usize {
self.calls.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl<F> HttpClient for CountingErrorClient<F>
where
F: Fn() -> Error + Send + Sync,
{
fn get(
&self,
_url: &str,
_headers: &HeaderMap,
_timeout: Option<Duration>,
) -> self_update::Result<Box<dyn HttpResponse>> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Err((self.make_error)())
}
}
#[test]
fn injected_rate_limited_error_is_not_retried() {
let url = "https://example.com/releases";
let client = Arc::new(CountingErrorClient::new(move || {
Error::http_status_error_with_headers(429, url, &HeaderMap::new())
}));
let result = self_update::backends::github::ReleaseList::configure()
.repo_owner("o")
.repo_name("r")
.http_client(client.clone())
.retries(3)
.build()
.unwrap()
.fetch();
assert!(
matches!(result, Err(Error::RateLimited { status: 429, .. })),
"expected Error::RateLimited, got {:?}",
result
);
assert_eq!(
client.calls(),
1,
"a RateLimited error must end the retry loop immediately: exactly one call to the \
injected transport, even with retries = 3"
);
}
#[test]
fn injected_non_rate_limited_error_still_consumes_the_retry_budget() {
let url = "https://example.com/releases";
let client = Arc::new(CountingErrorClient::new(move || {
Error::http_status_error(500, url)
}));
let result = self_update::backends::github::ReleaseList::configure()
.repo_owner("o")
.repo_name("r")
.http_client(client.clone())
.retries(3)
.build()
.unwrap()
.fetch();
assert!(
matches!(result, Err(Error::HttpStatus { status: 500, .. })),
"expected Error::HttpStatus, got {:?}",
result
);
assert_eq!(
client.calls(),
4,
"a non-RateLimited error must consume the whole retry budget: 1 initial attempt + 3 \
retries = 4 calls to the injected transport"
);
}
#[test]
fn injected_403_with_zero_retry_after_is_unauthorized_and_still_consumes_the_retry_budget() {
let url = "https://example.com/releases";
let client = Arc::new(CountingErrorClient::new(move || {
Error::http_status_error_with_headers(403, url, &headers(&[("retry-after", "0")]))
}));
let result = self_update::backends::github::ReleaseList::configure()
.repo_owner("o")
.repo_name("r")
.http_client(client.clone())
.retries(3)
.build()
.unwrap()
.fetch();
assert!(
matches!(result, Err(Error::Unauthorized { status: 403, .. })),
"a 403 with Retry-After: 0 and no other rate-limit signal must classify as Unauthorized, \
got {:?}",
result
);
assert_eq!(
client.calls(),
4,
"Unauthorized is not short-circuited: 1 initial attempt + 3 retries = 4 calls to the \
injected transport, exactly as a non-rate-limited 500 would consume"
);
}
#[test]
fn http_status_error_with_headers_builds_rate_limited_for_each_triggering_shape() {
let spent_primary = Error::http_status_error_with_headers(
403,
"https://api.github.com/repos/o/r/releases",
&headers(&[
("x-ratelimit-remaining", "0"),
("x-ratelimit-reset", &reset_epoch(600)),
]),
);
assert!(
matches!(spent_primary, Error::RateLimited { status: 403, .. }),
"403 + spent quota must be RateLimited, got {spent_primary:?}"
);
let secondary = Error::http_status_error_with_headers(
403,
"https://api.github.com/repos/o/r/releases",
&headers(&[("x-ratelimit-remaining", "57"), ("retry-after", "60")]),
);
assert!(
matches!(secondary, Error::RateLimited { status: 403, .. }),
"403 + Retry-After (secondary limit, quota remaining) must be RateLimited, got \
{secondary:?}"
);
let gitlab = Error::http_status_error_with_headers(
403,
"https://gitlab.com/api/v4/projects/o%2Fr/releases",
&headers(&[("RateLimit-Remaining", "0")]),
);
assert!(
matches!(gitlab, Error::RateLimited { status: 403, .. }),
"gitlab's un-prefixed spent-quota spelling must be RateLimited, got {gitlab:?}"
);
let bare_429 = Error::http_status_error_with_headers(
429,
"https://example.com/releases",
&HeaderMap::new(),
);
assert!(
matches!(bare_429, Error::RateLimited { status: 429, .. }),
"a 429 with no quota headers at all must still be RateLimited, got {bare_429:?}"
);
}
#[test]
fn http_status_error_with_headers_does_not_rate_limit_other_shapes() {
let bare_403 =
Error::http_status_error_with_headers(403, "https://example.com/x", &HeaderMap::new());
assert!(
matches!(bare_403, Error::Unauthorized { status: 403, .. }),
"a bare 403 must stay Unauthorized, got {bare_403:?}"
);
let quota_remains = Error::http_status_error_with_headers(
403,
"https://example.com/x",
&headers(&[
("x-ratelimit-remaining", "57"),
("x-ratelimit-reset", &reset_epoch(600)),
]),
);
assert!(
matches!(quota_remains, Error::Unauthorized { status: 403, .. }),
"a 403 with quota remaining must stay Unauthorized, got {quota_remains:?}"
);
let unauth_401 = Error::http_status_error_with_headers(
401,
"https://example.com/x",
&headers(&[("x-ratelimit-remaining", "0"), ("retry-after", "60")]),
);
assert!(
matches!(unauth_401, Error::Unauthorized { status: 401, .. }),
"a 401 must stay Unauthorized regardless of quota headers, got {unauth_401:?}"
);
let not_found = Error::http_status_error_with_headers(
404,
"https://example.com/x",
&headers(&[("x-ratelimit-remaining", "0"), ("retry-after", "60")]),
);
assert!(
matches!(not_found, Error::NotFound { .. }),
"a 404 must stay NotFound regardless of quota headers, got {not_found:?}"
);
let server_error = Error::http_status_error_with_headers(
500,
"https://example.com/x",
&headers(&[("x-ratelimit-remaining", "0"), ("retry-after", "60")]),
);
assert!(
matches!(server_error, Error::HttpStatus { status: 500, .. }),
"a 500 must stay HttpStatus regardless of quota headers, got {server_error:?}"
);
}
#[test]
fn a_spent_quota_403_is_no_longer_matched_as_unauthorized() {
let err = Error::http_status_error_with_headers(
403,
"https://api.github.com/repos/o/r/releases",
&headers(&[("x-ratelimit-remaining", "0")]),
);
assert!(
!matches!(err, Error::Unauthorized { .. }),
"a rate-limited 403 must no longer match Unauthorized (the 0.x/1.0 behaviour), got {err:?}"
);
assert!(
matches!(err, Error::RateLimited { status: 403, .. }),
"it must match RateLimited instead, got {err:?}"
);
assert_eq!(err.http_status(), Some(403));
}
#[test]
fn rate_limited_accessors_and_display_from_outside() {
let err = Error::http_status_error_with_headers(
429,
"https://api.github.com/repos/o/r/releases",
&headers(&[("retry-after", "60")]),
);
assert_eq!(err.http_status(), Some(429));
assert_eq!(err.url(), Some("https://api.github.com/repos/o/r/releases"));
assert!(
err.source().is_none(),
"RateLimited is field-only: no chained source()"
);
let shown = err.to_string();
assert!(shown.starts_with("RateLimitedError: "), "got: {shown}");
assert!(
shown.contains("https://api.github.com/repos/o/r/releases"),
"Display must name the request URL, got: {shown}"
);
assert!(
shown.contains("60"),
"Display must render the known wait, got: {shown}"
);
let described = match &err {
Error::RateLimited { status, .. } => format!("rate-limited {status}"),
_ => "other".to_string(),
};
assert_eq!(described, "rate-limited 429");
}
#[test]
fn rate_limit_delay_precedence_from_outside() {
let both = Error::http_status_error_with_headers(
429,
"https://example.com/x",
&headers(&[
("retry-after", "30"),
("x-ratelimit-reset", &reset_epoch(3600)),
]),
);
assert_eq!(
both.rate_limit_delay(),
Some(Duration::from_secs(30)),
"Retry-After must take precedence over the reset instant"
);
let retry_only = Error::http_status_error_with_headers(
403,
"https://example.com/x",
&headers(&[("retry-after", "45")]),
);
assert_eq!(retry_only.rate_limit_delay(), Some(Duration::from_secs(45)));
let reset_only = Error::http_status_error_with_headers(
403,
"https://example.com/x",
&headers(&[
("x-ratelimit-remaining", "0"),
("x-ratelimit-reset", &reset_epoch(600)),
]),
);
let wait = reset_only
.rate_limit_delay()
.expect("a future reset instant must yield a wait");
assert!(
wait > Duration::from_secs(540) && wait <= Duration::from_secs(600),
"the derived wait must be ~600s (reset minus now), got {wait:?}"
);
let elapsed = Error::http_status_error_with_headers(
403,
"https://example.com/x",
&headers(&[
("x-ratelimit-remaining", "0"),
("x-ratelimit-reset", &reset_epoch(-3600)),
]),
);
assert!(
matches!(elapsed, Error::RateLimited { .. }),
"an elapsed window is still a rate limit, got {elapsed:?}"
);
assert_eq!(
elapsed.rate_limit_delay(),
None,
"an elapsed reset instant yields no wait"
);
let bare =
Error::http_status_error_with_headers(429, "https://example.com/x", &HeaderMap::new());
assert_eq!(bare.rate_limit_delay(), None);
assert_eq!(
Error::http_status_error(404, "https://example.com/x").rate_limit_delay(),
None,
"rate_limit_delay must be None for a non-RateLimited variant"
);
assert_eq!(Error::Aborted.rate_limit_delay(), None);
}
#[test]
fn rate_limit_delay_rejects_absurd_server_supplied_waits_from_outside() {
let absurd_retry = Error::http_status_error_with_headers(
429,
"https://example.com/x",
&headers(&[("retry-after", "604800")]),
);
assert!(matches!(
absurd_retry,
Error::RateLimited { status: 429, .. }
));
assert_eq!(
absurd_retry.rate_limit_delay(),
None,
"an over-ceiling Retry-After must yield no wait, not a week-long sleep"
);
let absurd_reset = Error::http_status_error_with_headers(
429,
"https://example.com/x",
&headers(&[("x-ratelimit-reset", &reset_epoch(30 * 24 * 3600))]),
);
assert_eq!(
absurd_reset.rate_limit_delay(),
None,
"an over-ceiling reset instant must yield no wait"
);
let at_ceiling = Error::http_status_error_with_headers(
429,
"https://example.com/x",
&headers(&[("retry-after", "86400")]),
);
assert_eq!(
at_ceiling.rate_limit_delay(),
Some(Duration::from_secs(86400)),
"a Retry-After exactly at the 24h ceiling must be honoured"
);
let over_ceiling_403 = Error::http_status_error_with_headers(
403,
"https://example.com/x",
&headers(&[("retry-after", "604800")]),
);
assert!(
matches!(over_ceiling_403, Error::Unauthorized { status: 403, .. }),
"an over-ceiling Retry-After must not promote a 403 to RateLimited, got \
{over_ceiling_403:?}"
);
}
#[test]
fn header_blind_http_status_error_still_classifies_429_as_rate_limited() {
let err = Error::http_status_error(429, "https://example.com/x");
let Error::RateLimited {
status,
reset_at,
retry_after,
..
} = err
else {
panic!("the header-blind constructor must classify a 429 as RateLimited, got {err:?}");
};
assert_eq!(status, 429);
assert_eq!(
reset_at, None,
"a header-blind 429 has no reset instant to carry"
);
assert_eq!(
retry_after, None,
"a header-blind 429 has no Retry-After to carry"
);
assert_eq!(
err.rate_limit_delay(),
None,
"with neither wait field there is no known wait"
);
assert_eq!(err.http_status(), Some(429));
let err = Error::http_status_error(403, "https://example.com/x");
assert!(
matches!(err, Error::Unauthorized { status: 403, .. }),
"a header-blind 403 must stay Unauthorized, got {err:?}"
);
let err = Error::http_status_error(401, "https://example.com/x");
assert!(
matches!(err, Error::Unauthorized { status: 401, .. }),
"a header-blind 401 must stay Unauthorized, got {err:?}"
);
}