1use std::ops::ControlFlow;
15
16use crate::PreflightCheck;
17use crate::retry::{RetryPolicy, is_retriable, retry_sync};
18
19pub const REPO_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
23
24pub enum RepoProbe {
27 Body(String),
29 Missing,
31 AuthDenied,
33 RateLimited,
37 Inconclusive(String),
39}
40
41pub fn response_is_rate_limited(headers: &reqwest::header::HeaderMap) -> bool {
45 if headers.contains_key("retry-after") {
46 return true;
47 }
48 headers
49 .get("x-ratelimit-remaining")
50 .and_then(|v| v.to_str().ok())
51 .map(|v| v.trim() == "0")
52 .unwrap_or(false)
53}
54
55pub fn is_secondary_rate_limit_signature(
67 status: u16,
68 message: &str,
69 documentation_url: Option<&str>,
70) -> bool {
71 if status != 403 && status != 429 {
72 return false;
73 }
74 if message.to_lowercase().contains("secondary rate limit") {
75 return true;
76 }
77 documentation_url.is_some_and(|u| u.contains("secondary-rate-limits"))
78}
79
80pub fn is_rate_limit_signature(
88 status: u16,
89 message: &str,
90 documentation_url: Option<&str>,
91) -> bool {
92 if status == 429 {
93 return true;
94 }
95 if status != 403 {
96 return false;
97 }
98 message.to_lowercase().contains("rate limit")
99 || documentation_url.is_some_and(|u| u.contains("rate-limit"))
100}
101
102pub struct RepoAccessOutcomes {
112 pub push_denied: PreflightCheck,
114 pub missing_or_denied: PreflightCheck,
116}
117
118pub fn github_repo_push_check(
130 url: &str,
131 owner: &str,
132 repo: &str,
133 token: Option<&str>,
134 policy: &RetryPolicy,
135 outcomes: RepoAccessOutcomes,
136) -> PreflightCheck {
137 let client = match crate::http::blocking_client(REPO_PROBE_TIMEOUT) {
138 Ok(c) => c,
139 Err(e) => {
140 return PreflightCheck::Warning(format!(
141 "could not probe {owner}/{repo} write access ({e}); verify the repo and token manually"
142 ));
143 }
144 };
145 probe_to_push_check(
146 github_repo_probe(&client, url, token, policy),
147 owner,
148 repo,
149 outcomes,
150 )
151}
152
153pub fn probe_to_push_check(
156 probe: RepoProbe,
157 owner: &str,
158 repo: &str,
159 outcomes: RepoAccessOutcomes,
160) -> PreflightCheck {
161 match probe {
162 RepoProbe::Body(body) => match serde_json::from_str::<serde_json::Value>(&body) {
163 Ok(v) => match v.pointer("/permissions/push").and_then(|p| p.as_bool()) {
164 Some(true) => PreflightCheck::Pass,
165 Some(false) => outcomes.push_denied,
166 None => PreflightCheck::Warning(format!(
167 "could not determine push access to {owner}/{repo} (no permissions in API \
168 response); verify the token scope manually"
169 )),
170 },
171 Err(_) => PreflightCheck::Warning(format!(
172 "could not parse {owner}/{repo} API response; verify the repo and token manually"
173 )),
174 },
175 RepoProbe::Missing | RepoProbe::AuthDenied => outcomes.missing_or_denied,
176 RepoProbe::RateLimited => PreflightCheck::Warning(format!(
180 "GitHub API rate-limited while probing {owner}/{repo}; could not verify write access \
181 — verify the repo and token manually"
182 )),
183 RepoProbe::Inconclusive(reason) => PreflightCheck::Warning(format!(
184 "could not probe {owner}/{repo} write access ({reason}); verify the repo and token manually"
185 )),
186 }
187}
188
189pub fn github_repo_probe(
199 client: &reqwest::blocking::Client,
200 url: &str,
201 token: Option<&str>,
202 policy: &RetryPolicy,
203) -> RepoProbe {
204 let token = token.map(str::to_string);
205 let outcome = retry_sync(policy, |_attempt| {
206 let mut b = client
207 .get(url)
208 .header("Accept", "application/vnd.github+json")
209 .header("X-GitHub-Api-Version", "2022-11-28");
210 if let Some(ref tok) = token
211 && !tok.is_empty()
212 {
213 b = b.header("Authorization", format!("Bearer {tok}"));
214 }
215 match b.send() {
216 Ok(resp) => {
217 let code = resp.status().as_u16();
218 let rate_limited = response_is_rate_limited(resp.headers());
221 if resp.status().is_success() {
222 Ok(RepoProbe::Body(resp.text().unwrap_or_default()))
223 } else if resp.status().is_server_error() {
224 Err(ControlFlow::Continue(RepoProbe::Inconclusive(format!(
225 "HTTP {code}"
226 ))))
227 } else if code == 429 || ((code == 403 || code == 401) && rate_limited) {
228 Ok(RepoProbe::RateLimited)
229 } else if code == 404 {
230 Ok(RepoProbe::Missing)
231 } else if code == 403 || code == 401 {
232 Ok(RepoProbe::AuthDenied)
233 } else {
234 Ok(RepoProbe::Inconclusive(format!("unexpected HTTP {code}")))
235 }
236 }
237 Err(e) => {
238 let msg = format!("network failure: {e}");
239 if is_retriable(&e) {
240 Err(ControlFlow::Continue(RepoProbe::Inconclusive(msg)))
241 } else {
242 Err(ControlFlow::Break(RepoProbe::Inconclusive(msg)))
243 }
244 }
245 }
246 });
247 match outcome {
250 Ok(p) | Err(p) => p,
251 }
252}
253
254#[cfg(test)]
255mod push_check_tests {
256 use super::*;
261
262 fn outcomes() -> RepoAccessOutcomes {
263 RepoAccessOutcomes {
264 push_denied: PreflightCheck::Blocker("push denied".into()),
265 missing_or_denied: PreflightCheck::Blocker("missing or denied".into()),
266 }
267 }
268
269 #[test]
270 fn push_true_passes() {
271 let probe = RepoProbe::Body(r#"{"permissions":{"push":true}}"#.into());
272 assert_eq!(
273 probe_to_push_check(probe, "o", "r", outcomes()),
274 PreflightCheck::Pass
275 );
276 }
277
278 #[test]
279 fn push_false_returns_caller_push_denied() {
280 let probe = RepoProbe::Body(r#"{"permissions":{"push":false}}"#.into());
281 assert_eq!(
282 probe_to_push_check(probe, "o", "r", outcomes()),
283 PreflightCheck::Blocker("push denied".into())
284 );
285 }
286
287 #[test]
288 fn permissions_absent_warns() {
289 let probe = RepoProbe::Body(r#"{"full_name":"o/r"}"#.into());
290 match probe_to_push_check(probe, "o", "r", outcomes()) {
291 PreflightCheck::Warning(msg) => {
292 assert!(msg.contains("could not determine push access"), "{msg}")
293 }
294 other => panic!("expected Warning, got {other:?}"),
295 }
296 }
297
298 #[test]
299 fn unparsable_body_warns() {
300 let probe = RepoProbe::Body("not json".into());
301 match probe_to_push_check(probe, "o", "r", outcomes()) {
302 PreflightCheck::Warning(msg) => {
303 assert!(msg.contains("could not parse o/r"), "{msg}")
304 }
305 other => panic!("expected Warning, got {other:?}"),
306 }
307 }
308
309 #[test]
310 fn missing_and_auth_denied_return_caller_outcome() {
311 for probe in [RepoProbe::Missing, RepoProbe::AuthDenied] {
312 assert_eq!(
313 probe_to_push_check(probe, "o", "r", outcomes()),
314 PreflightCheck::Blocker("missing or denied".into())
315 );
316 }
317 }
318
319 #[test]
320 fn rate_limited_warns_never_escalates() {
321 match probe_to_push_check(RepoProbe::RateLimited, "o", "r", outcomes()) {
322 PreflightCheck::Warning(msg) => assert!(msg.contains("rate-limited"), "{msg}"),
323 other => panic!("expected Warning, got {other:?}"),
324 }
325 }
326
327 #[test]
328 fn inconclusive_warns_with_reason() {
329 let probe = RepoProbe::Inconclusive("HTTP 500".into());
330 match probe_to_push_check(probe, "o", "r", outcomes()) {
331 PreflightCheck::Warning(msg) => assert!(msg.contains("HTTP 500"), "{msg}"),
332 other => panic!("expected Warning, got {other:?}"),
333 }
334 }
335}
336
337#[cfg(test)]
338mod rate_limit_signature_tests {
339 use super::*;
340
341 #[test]
342 fn secondary_matches_message_or_doc_url_on_403_and_429() {
343 for status in [403u16, 429] {
344 assert!(is_secondary_rate_limit_signature(
345 status,
346 "You have exceeded a secondary rate limit",
347 None
348 ));
349 assert!(is_secondary_rate_limit_signature(
350 status,
351 "blocked",
352 Some("https://docs.github.com/rest/overview#secondary-rate-limits")
353 ));
354 }
355 }
356
357 #[test]
358 fn secondary_rejects_other_statuses_and_plain_403() {
359 assert!(!is_secondary_rate_limit_signature(
360 500,
361 "secondary rate limit",
362 None
363 ));
364 assert!(!is_secondary_rate_limit_signature(
365 403,
366 "Bad credentials",
367 Some("https://docs.github.com/rest")
368 ));
369 }
370
371 #[test]
372 fn rate_limit_signature_accepts_any_429() {
373 assert!(is_rate_limit_signature(429, "", None));
374 }
375
376 #[test]
377 fn rate_limit_signature_needs_body_signal_on_403() {
378 assert!(is_rate_limit_signature(
379 403,
380 "API rate limit exceeded for user ID 1",
381 None
382 ));
383 assert!(is_rate_limit_signature(
384 403,
385 "forbidden",
386 Some("https://docs.github.com/rest/overview/rate-limits-for-the-rest-api")
387 ));
388 assert!(!is_rate_limit_signature(
391 403,
392 "Resource not accessible by integration",
393 Some("https://docs.github.com/rest")
394 ));
395 assert!(!is_rate_limit_signature(401, "rate limit", None));
396 }
397}