1use std::ops::ControlFlow;
15
16use crate::PreflightCheck;
17use crate::log::StageLogger;
18use crate::retry::{RetryLog, RetryPolicy, is_retriable, retry_sync_deadline};
19
20pub const REPO_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
24
25pub enum RepoProbe {
28 Body(String),
30 Missing,
32 AuthDenied,
34 RateLimited,
38 Inconclusive(String),
40}
41
42impl std::fmt::Display for RepoProbe {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 RepoProbe::Body(_) => f.write_str("probe succeeded"),
46 RepoProbe::Missing => f.write_str("repo not found (404)"),
47 RepoProbe::AuthDenied => f.write_str("access denied (401/403)"),
48 RepoProbe::RateLimited => f.write_str("rate limited"),
49 RepoProbe::Inconclusive(reason) => f.write_str(reason),
50 }
51 }
52}
53
54pub fn response_is_rate_limited(headers: &reqwest::header::HeaderMap) -> bool {
58 if headers.contains_key("retry-after") {
59 return true;
60 }
61 headers
62 .get("x-ratelimit-remaining")
63 .and_then(|v| v.to_str().ok())
64 .map(|v| v.trim() == "0")
65 .unwrap_or(false)
66}
67
68pub fn is_secondary_rate_limit_signature(
80 status: u16,
81 message: &str,
82 documentation_url: Option<&str>,
83) -> bool {
84 if status != 403 && status != 429 {
85 return false;
86 }
87 if message.to_lowercase().contains("secondary rate limit") {
88 return true;
89 }
90 documentation_url.is_some_and(|u| u.contains("secondary-rate-limits"))
91}
92
93pub fn is_rate_limit_signature(
101 status: u16,
102 message: &str,
103 documentation_url: Option<&str>,
104) -> bool {
105 if status == 429 {
106 return true;
107 }
108 if status != 403 {
109 return false;
110 }
111 message.to_lowercase().contains("rate limit")
112 || documentation_url.is_some_and(|u| u.contains("rate-limit"))
113}
114
115pub struct RepoAccessOutcomes {
126 pub push_denied: PreflightCheck,
128 pub missing_or_denied: PreflightCheck,
130}
131
132#[derive(Clone, Copy)]
140pub struct GithubRepoProbe<'a> {
141 pub owner: &'a str,
143 pub repo: &'a str,
145 pub token: Option<&'a str>,
147 pub policy: &'a RetryPolicy,
149 pub deadline: Option<std::time::Instant>,
151 pub strict: bool,
153}
154
155pub fn github_repo_push_check(
174 url: &str,
175 probe: &GithubRepoProbe<'_>,
176 outcomes: RepoAccessOutcomes,
177 log: &StageLogger,
178) -> PreflightCheck {
179 let GithubRepoProbe {
180 owner,
181 repo,
182 token,
183 policy,
184 deadline,
185 strict,
186 } = *probe;
187 let client = match crate::http::blocking_client(REPO_PROBE_TIMEOUT) {
188 Ok(c) => c,
189 Err(e) => {
190 return indeterminate_check(
191 strict,
192 format!(
193 "could not probe {owner}/{repo} write access ({e}); verify the repo and token manually"
194 ),
195 );
196 }
197 };
198 probe_to_push_check(
199 github_repo_probe(&client, url, token, policy, deadline, log),
200 owner,
201 repo,
202 outcomes,
203 strict,
204 )
205}
206
207pub fn indeterminate_check(strict: bool, msg: String) -> PreflightCheck {
212 if strict {
213 PreflightCheck::Blocker(msg)
214 } else {
215 PreflightCheck::Warning(msg)
216 }
217}
218
219pub fn probe_to_push_check(
222 probe: RepoProbe,
223 owner: &str,
224 repo: &str,
225 outcomes: RepoAccessOutcomes,
226 strict: bool,
227) -> PreflightCheck {
228 match probe {
229 RepoProbe::Body(body) => match serde_json::from_str::<serde_json::Value>(&body) {
230 Ok(v) => match v.pointer("/permissions/push").and_then(|p| p.as_bool()) {
231 Some(true) => PreflightCheck::Pass,
232 Some(false) => outcomes.push_denied,
233 None => indeterminate_check(
234 strict,
235 format!(
236 "could not determine push access to {owner}/{repo} (no permissions in API \
237 response); verify the token scope manually"
238 ),
239 ),
240 },
241 Err(_) => indeterminate_check(
242 strict,
243 format!(
244 "could not parse {owner}/{repo} API response; verify the repo and token manually"
245 ),
246 ),
247 },
248 RepoProbe::Missing | RepoProbe::AuthDenied => outcomes.missing_or_denied,
249 RepoProbe::RateLimited => indeterminate_check(
254 strict,
255 format!(
256 "GitHub API rate-limited while probing {owner}/{repo}; could not verify write \
257 access — verify the repo and token manually"
258 ),
259 ),
260 RepoProbe::Inconclusive(reason) => indeterminate_check(
261 strict,
262 format!(
263 "could not probe {owner}/{repo} write access ({reason}); verify the repo and token manually"
264 ),
265 ),
266 }
267}
268
269pub fn github_repo_probe(
280 client: &reqwest::blocking::Client,
281 url: &str,
282 token: Option<&str>,
283 policy: &RetryPolicy,
284 deadline: Option<std::time::Instant>,
285 log: &StageLogger,
286) -> RepoProbe {
287 let rlog = RetryLog::new("github repo probe", log);
288 let token = token.map(str::to_string);
289 let outcome = retry_sync_deadline(rlog, policy, deadline, |_attempt| {
290 let mut b = client
291 .get(url)
292 .header("Accept", "application/vnd.github+json")
293 .header("X-GitHub-Api-Version", "2022-11-28");
294 if let Some(ref tok) = token
295 && !tok.is_empty()
296 {
297 b = b.header("Authorization", format!("Bearer {tok}"));
298 }
299 match b.send() {
300 Ok(resp) => {
301 let code = resp.status().as_u16();
302 let rate_limited = response_is_rate_limited(resp.headers());
305 if resp.status().is_success() {
306 Ok(RepoProbe::Body(resp.text().unwrap_or_default()))
307 } else if resp.status().is_server_error() {
308 Err(ControlFlow::Continue(RepoProbe::Inconclusive(format!(
309 "HTTP {code}"
310 ))))
311 } else if code == 429 || ((code == 403 || code == 401) && rate_limited) {
312 Ok(RepoProbe::RateLimited)
313 } else if code == 404 {
314 Ok(RepoProbe::Missing)
315 } else if code == 403 || code == 401 {
316 Ok(RepoProbe::AuthDenied)
317 } else {
318 Ok(RepoProbe::Inconclusive(format!("unexpected HTTP {code}")))
319 }
320 }
321 Err(e) => {
322 let msg = format!("network failure: {e}");
323 if is_retriable(&e) {
324 Err(ControlFlow::Continue(RepoProbe::Inconclusive(msg)))
325 } else {
326 Err(ControlFlow::Break(RepoProbe::Inconclusive(msg)))
327 }
328 }
329 }
330 });
331 match outcome {
334 Ok(p) | Err(p) => p,
335 }
336}
337
338#[cfg(test)]
339mod push_check_tests {
340 use super::*;
345
346 fn outcomes() -> RepoAccessOutcomes {
347 RepoAccessOutcomes {
348 push_denied: PreflightCheck::Blocker("push denied".into()),
349 missing_or_denied: PreflightCheck::Blocker("missing or denied".into()),
350 }
351 }
352
353 #[test]
354 fn push_true_passes() {
355 let probe = RepoProbe::Body(r#"{"permissions":{"push":true}}"#.into());
356 assert_eq!(
357 probe_to_push_check(probe, "o", "r", outcomes(), false),
358 PreflightCheck::Pass
359 );
360 }
361
362 #[test]
363 fn push_false_returns_caller_push_denied() {
364 let probe = RepoProbe::Body(r#"{"permissions":{"push":false}}"#.into());
365 assert_eq!(
366 probe_to_push_check(probe, "o", "r", outcomes(), false),
367 PreflightCheck::Blocker("push denied".into())
368 );
369 }
370
371 #[test]
372 fn permissions_absent_warns() {
373 let probe = RepoProbe::Body(r#"{"full_name":"o/r"}"#.into());
374 match probe_to_push_check(probe, "o", "r", outcomes(), false) {
375 PreflightCheck::Warning(msg) => {
376 assert!(msg.contains("could not determine push access"), "{msg}")
377 }
378 other => panic!("expected Warning, got {other:?}"),
379 }
380 }
381
382 #[test]
383 fn unparsable_body_warns() {
384 let probe = RepoProbe::Body("not json".into());
385 match probe_to_push_check(probe, "o", "r", outcomes(), false) {
386 PreflightCheck::Warning(msg) => {
387 assert!(msg.contains("could not parse o/r"), "{msg}")
388 }
389 other => panic!("expected Warning, got {other:?}"),
390 }
391 }
392
393 #[test]
394 fn missing_and_auth_denied_return_caller_outcome() {
395 for probe in [RepoProbe::Missing, RepoProbe::AuthDenied] {
396 assert_eq!(
397 probe_to_push_check(probe, "o", "r", outcomes(), false),
398 PreflightCheck::Blocker("missing or denied".into())
399 );
400 }
401 }
402
403 #[test]
404 fn rate_limited_warns_never_escalates() {
405 match probe_to_push_check(RepoProbe::RateLimited, "o", "r", outcomes(), false) {
406 PreflightCheck::Warning(msg) => assert!(msg.contains("rate-limited"), "{msg}"),
407 other => panic!("expected Warning, got {other:?}"),
408 }
409 }
410
411 #[test]
412 fn strict_promotes_indeterminate_arms_to_blocker() {
413 for probe in [
414 RepoProbe::RateLimited,
415 RepoProbe::Inconclusive("HTTP 500".into()),
416 RepoProbe::Body(r#"{"full_name":"o/r"}"#.into()),
417 RepoProbe::Body("not json".into()),
418 ] {
419 match probe_to_push_check(probe, "o", "r", outcomes(), true) {
420 PreflightCheck::Blocker(_) => {}
421 other => panic!("strict must promote indeterminate to Blocker, got {other:?}"),
422 }
423 }
424 }
425
426 #[test]
427 fn strict_leaves_definitive_arms_unchanged() {
428 let probe = RepoProbe::Body(r#"{"permissions":{"push":true}}"#.into());
431 assert_eq!(
432 probe_to_push_check(probe, "o", "r", outcomes(), true),
433 PreflightCheck::Pass
434 );
435 assert_eq!(
436 probe_to_push_check(RepoProbe::Missing, "o", "r", outcomes(), true),
437 PreflightCheck::Blocker("missing or denied".into())
438 );
439 }
440
441 #[test]
442 fn inconclusive_warns_with_reason() {
443 let probe = RepoProbe::Inconclusive("HTTP 500".into());
444 match probe_to_push_check(probe, "o", "r", outcomes(), false) {
445 PreflightCheck::Warning(msg) => assert!(msg.contains("HTTP 500"), "{msg}"),
446 other => panic!("expected Warning, got {other:?}"),
447 }
448 }
449}
450
451#[cfg(test)]
452mod rate_limit_signature_tests {
453 use super::*;
454
455 #[test]
456 fn secondary_matches_message_or_doc_url_on_403_and_429() {
457 for status in [403u16, 429] {
458 assert!(is_secondary_rate_limit_signature(
459 status,
460 "You have exceeded a secondary rate limit",
461 None
462 ));
463 assert!(is_secondary_rate_limit_signature(
464 status,
465 "blocked",
466 Some("https://docs.github.com/rest/overview#secondary-rate-limits")
467 ));
468 }
469 }
470
471 #[test]
472 fn secondary_rejects_other_statuses_and_plain_403() {
473 assert!(!is_secondary_rate_limit_signature(
474 500,
475 "secondary rate limit",
476 None
477 ));
478 assert!(!is_secondary_rate_limit_signature(
479 403,
480 "Bad credentials",
481 Some("https://docs.github.com/rest")
482 ));
483 }
484
485 #[test]
486 fn rate_limit_signature_accepts_any_429() {
487 assert!(is_rate_limit_signature(429, "", None));
488 }
489
490 #[test]
491 fn rate_limit_signature_needs_body_signal_on_403() {
492 assert!(is_rate_limit_signature(
493 403,
494 "API rate limit exceeded for user ID 1",
495 None
496 ));
497 assert!(is_rate_limit_signature(
498 403,
499 "forbidden",
500 Some("https://docs.github.com/rest/overview/rate-limits-for-the-rest-api")
501 ));
502 assert!(!is_rate_limit_signature(
505 403,
506 "Resource not accessible by integration",
507 Some("https://docs.github.com/rest")
508 ));
509 assert!(!is_rate_limit_signature(401, "rate limit", None));
510 }
511}