use std::ops::ControlFlow;
use crate::PreflightCheck;
use crate::retry::{RetryPolicy, is_retriable, retry_sync};
pub const REPO_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
pub enum RepoProbe {
Body(String),
Missing,
AuthDenied,
RateLimited,
Inconclusive(String),
}
pub fn response_is_rate_limited(headers: &reqwest::header::HeaderMap) -> bool {
if headers.contains_key("retry-after") {
return true;
}
headers
.get("x-ratelimit-remaining")
.and_then(|v| v.to_str().ok())
.map(|v| v.trim() == "0")
.unwrap_or(false)
}
pub fn is_secondary_rate_limit_signature(
status: u16,
message: &str,
documentation_url: Option<&str>,
) -> bool {
if status != 403 && status != 429 {
return false;
}
if message.to_lowercase().contains("secondary rate limit") {
return true;
}
documentation_url.is_some_and(|u| u.contains("secondary-rate-limits"))
}
pub fn is_rate_limit_signature(
status: u16,
message: &str,
documentation_url: Option<&str>,
) -> bool {
if status == 429 {
return true;
}
if status != 403 {
return false;
}
message.to_lowercase().contains("rate limit")
|| documentation_url.is_some_and(|u| u.contains("rate-limit"))
}
pub struct RepoAccessOutcomes {
pub push_denied: PreflightCheck,
pub missing_or_denied: PreflightCheck,
}
pub fn github_repo_push_check(
url: &str,
owner: &str,
repo: &str,
token: Option<&str>,
policy: &RetryPolicy,
outcomes: RepoAccessOutcomes,
) -> PreflightCheck {
let client = match crate::http::blocking_client(REPO_PROBE_TIMEOUT) {
Ok(c) => c,
Err(e) => {
return PreflightCheck::Warning(format!(
"could not probe {owner}/{repo} write access ({e}); verify the repo and token manually"
));
}
};
probe_to_push_check(
github_repo_probe(&client, url, token, policy),
owner,
repo,
outcomes,
)
}
pub fn probe_to_push_check(
probe: RepoProbe,
owner: &str,
repo: &str,
outcomes: RepoAccessOutcomes,
) -> PreflightCheck {
match probe {
RepoProbe::Body(body) => match serde_json::from_str::<serde_json::Value>(&body) {
Ok(v) => match v.pointer("/permissions/push").and_then(|p| p.as_bool()) {
Some(true) => PreflightCheck::Pass,
Some(false) => outcomes.push_denied,
None => PreflightCheck::Warning(format!(
"could not determine push access to {owner}/{repo} (no permissions in API \
response); verify the token scope manually"
)),
},
Err(_) => PreflightCheck::Warning(format!(
"could not parse {owner}/{repo} API response; verify the repo and token manually"
)),
},
RepoProbe::Missing | RepoProbe::AuthDenied => outcomes.missing_or_denied,
RepoProbe::RateLimited => PreflightCheck::Warning(format!(
"GitHub API rate-limited while probing {owner}/{repo}; could not verify write access \
— verify the repo and token manually"
)),
RepoProbe::Inconclusive(reason) => PreflightCheck::Warning(format!(
"could not probe {owner}/{repo} write access ({reason}); verify the repo and token manually"
)),
}
}
pub fn github_repo_probe(
client: &reqwest::blocking::Client,
url: &str,
token: Option<&str>,
policy: &RetryPolicy,
) -> RepoProbe {
let token = token.map(str::to_string);
let outcome = retry_sync(policy, |_attempt| {
let mut b = client
.get(url)
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28");
if let Some(ref tok) = token
&& !tok.is_empty()
{
b = b.header("Authorization", format!("Bearer {tok}"));
}
match b.send() {
Ok(resp) => {
let code = resp.status().as_u16();
let rate_limited = response_is_rate_limited(resp.headers());
if resp.status().is_success() {
Ok(RepoProbe::Body(resp.text().unwrap_or_default()))
} else if resp.status().is_server_error() {
Err(ControlFlow::Continue(RepoProbe::Inconclusive(format!(
"HTTP {code}"
))))
} else if code == 429 || ((code == 403 || code == 401) && rate_limited) {
Ok(RepoProbe::RateLimited)
} else if code == 404 {
Ok(RepoProbe::Missing)
} else if code == 403 || code == 401 {
Ok(RepoProbe::AuthDenied)
} else {
Ok(RepoProbe::Inconclusive(format!("unexpected HTTP {code}")))
}
}
Err(e) => {
let msg = format!("network failure: {e}");
if is_retriable(&e) {
Err(ControlFlow::Continue(RepoProbe::Inconclusive(msg)))
} else {
Err(ControlFlow::Break(RepoProbe::Inconclusive(msg)))
}
}
}
});
match outcome {
Ok(p) | Err(p) => p,
}
}
#[cfg(test)]
mod push_check_tests {
use super::*;
fn outcomes() -> RepoAccessOutcomes {
RepoAccessOutcomes {
push_denied: PreflightCheck::Blocker("push denied".into()),
missing_or_denied: PreflightCheck::Blocker("missing or denied".into()),
}
}
#[test]
fn push_true_passes() {
let probe = RepoProbe::Body(r#"{"permissions":{"push":true}}"#.into());
assert_eq!(
probe_to_push_check(probe, "o", "r", outcomes()),
PreflightCheck::Pass
);
}
#[test]
fn push_false_returns_caller_push_denied() {
let probe = RepoProbe::Body(r#"{"permissions":{"push":false}}"#.into());
assert_eq!(
probe_to_push_check(probe, "o", "r", outcomes()),
PreflightCheck::Blocker("push denied".into())
);
}
#[test]
fn permissions_absent_warns() {
let probe = RepoProbe::Body(r#"{"full_name":"o/r"}"#.into());
match probe_to_push_check(probe, "o", "r", outcomes()) {
PreflightCheck::Warning(msg) => {
assert!(msg.contains("could not determine push access"), "{msg}")
}
other => panic!("expected Warning, got {other:?}"),
}
}
#[test]
fn unparsable_body_warns() {
let probe = RepoProbe::Body("not json".into());
match probe_to_push_check(probe, "o", "r", outcomes()) {
PreflightCheck::Warning(msg) => {
assert!(msg.contains("could not parse o/r"), "{msg}")
}
other => panic!("expected Warning, got {other:?}"),
}
}
#[test]
fn missing_and_auth_denied_return_caller_outcome() {
for probe in [RepoProbe::Missing, RepoProbe::AuthDenied] {
assert_eq!(
probe_to_push_check(probe, "o", "r", outcomes()),
PreflightCheck::Blocker("missing or denied".into())
);
}
}
#[test]
fn rate_limited_warns_never_escalates() {
match probe_to_push_check(RepoProbe::RateLimited, "o", "r", outcomes()) {
PreflightCheck::Warning(msg) => assert!(msg.contains("rate-limited"), "{msg}"),
other => panic!("expected Warning, got {other:?}"),
}
}
#[test]
fn inconclusive_warns_with_reason() {
let probe = RepoProbe::Inconclusive("HTTP 500".into());
match probe_to_push_check(probe, "o", "r", outcomes()) {
PreflightCheck::Warning(msg) => assert!(msg.contains("HTTP 500"), "{msg}"),
other => panic!("expected Warning, got {other:?}"),
}
}
}
#[cfg(test)]
mod rate_limit_signature_tests {
use super::*;
#[test]
fn secondary_matches_message_or_doc_url_on_403_and_429() {
for status in [403u16, 429] {
assert!(is_secondary_rate_limit_signature(
status,
"You have exceeded a secondary rate limit",
None
));
assert!(is_secondary_rate_limit_signature(
status,
"blocked",
Some("https://docs.github.com/rest/overview#secondary-rate-limits")
));
}
}
#[test]
fn secondary_rejects_other_statuses_and_plain_403() {
assert!(!is_secondary_rate_limit_signature(
500,
"secondary rate limit",
None
));
assert!(!is_secondary_rate_limit_signature(
403,
"Bad credentials",
Some("https://docs.github.com/rest")
));
}
#[test]
fn rate_limit_signature_accepts_any_429() {
assert!(is_rate_limit_signature(429, "", None));
}
#[test]
fn rate_limit_signature_needs_body_signal_on_403() {
assert!(is_rate_limit_signature(
403,
"API rate limit exceeded for user ID 1",
None
));
assert!(is_rate_limit_signature(
403,
"forbidden",
Some("https://docs.github.com/rest/overview/rate-limits-for-the-rest-api")
));
assert!(!is_rate_limit_signature(
403,
"Resource not accessible by integration",
Some("https://docs.github.com/rest")
));
assert!(!is_rate_limit_signature(401, "rate limit", None));
}
}