use std::ops::ControlFlow;
use crate::PreflightCheck;
use crate::log::StageLogger;
use crate::retry::{RetryLog, 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),
}
impl std::fmt::Display for RepoProbe {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RepoProbe::Body(_) => f.write_str("probe succeeded"),
RepoProbe::Missing => f.write_str("repo not found (404)"),
RepoProbe::AuthDenied => f.write_str("access denied (401/403)"),
RepoProbe::RateLimited => f.write_str("rate limited"),
RepoProbe::Inconclusive(reason) => f.write_str(reason),
}
}
}
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,
}
#[allow(clippy::too_many_arguments)]
pub fn github_repo_push_check(
url: &str,
owner: &str,
repo: &str,
token: Option<&str>,
policy: &RetryPolicy,
outcomes: RepoAccessOutcomes,
strict: bool,
log: &StageLogger,
) -> PreflightCheck {
let client = match crate::http::blocking_client(REPO_PROBE_TIMEOUT) {
Ok(c) => c,
Err(e) => {
return indeterminate_check(
strict,
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, log),
owner,
repo,
outcomes,
strict,
)
}
pub fn indeterminate_check(strict: bool, msg: String) -> PreflightCheck {
if strict {
PreflightCheck::Blocker(msg)
} else {
PreflightCheck::Warning(msg)
}
}
pub fn probe_to_push_check(
probe: RepoProbe,
owner: &str,
repo: &str,
outcomes: RepoAccessOutcomes,
strict: bool,
) -> 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 => indeterminate_check(
strict,
format!(
"could not determine push access to {owner}/{repo} (no permissions in API \
response); verify the token scope manually"
),
),
},
Err(_) => indeterminate_check(
strict,
format!(
"could not parse {owner}/{repo} API response; verify the repo and token manually"
),
),
},
RepoProbe::Missing | RepoProbe::AuthDenied => outcomes.missing_or_denied,
RepoProbe::RateLimited => indeterminate_check(
strict,
format!(
"GitHub API rate-limited while probing {owner}/{repo}; could not verify write \
access — verify the repo and token manually"
),
),
RepoProbe::Inconclusive(reason) => indeterminate_check(
strict,
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,
log: &StageLogger,
) -> RepoProbe {
let rlog = RetryLog::new("github repo probe", log);
let token = token.map(str::to_string);
let outcome = retry_sync(rlog, 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(), false),
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(), false),
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(), false) {
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(), false) {
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(), false),
PreflightCheck::Blocker("missing or denied".into())
);
}
}
#[test]
fn rate_limited_warns_never_escalates() {
match probe_to_push_check(RepoProbe::RateLimited, "o", "r", outcomes(), false) {
PreflightCheck::Warning(msg) => assert!(msg.contains("rate-limited"), "{msg}"),
other => panic!("expected Warning, got {other:?}"),
}
}
#[test]
fn strict_promotes_indeterminate_arms_to_blocker() {
for probe in [
RepoProbe::RateLimited,
RepoProbe::Inconclusive("HTTP 500".into()),
RepoProbe::Body(r#"{"full_name":"o/r"}"#.into()),
RepoProbe::Body("not json".into()),
] {
match probe_to_push_check(probe, "o", "r", outcomes(), true) {
PreflightCheck::Blocker(_) => {}
other => panic!("strict must promote indeterminate to Blocker, got {other:?}"),
}
}
}
#[test]
fn strict_leaves_definitive_arms_unchanged() {
let probe = RepoProbe::Body(r#"{"permissions":{"push":true}}"#.into());
assert_eq!(
probe_to_push_check(probe, "o", "r", outcomes(), true),
PreflightCheck::Pass
);
assert_eq!(
probe_to_push_check(RepoProbe::Missing, "o", "r", outcomes(), true),
PreflightCheck::Blocker("missing or denied".into())
);
}
#[test]
fn inconclusive_warns_with_reason() {
let probe = RepoProbe::Inconclusive("HTTP 500".into());
match probe_to_push_check(probe, "o", "r", outcomes(), false) {
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));
}
}