1use std::ops::ControlFlow;
15
16use crate::PreflightCheck;
17use crate::log::StageLogger;
18use crate::retry::{RetryLog, RetryPolicy, is_retriable, retry_sync};
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#[allow(clippy::too_many_arguments)]
147pub fn github_repo_push_check(
148 url: &str,
149 owner: &str,
150 repo: &str,
151 token: Option<&str>,
152 policy: &RetryPolicy,
153 outcomes: RepoAccessOutcomes,
154 strict: bool,
155 log: &StageLogger,
156) -> PreflightCheck {
157 let client = match crate::http::blocking_client(REPO_PROBE_TIMEOUT) {
158 Ok(c) => c,
159 Err(e) => {
160 return indeterminate_check(
161 strict,
162 format!(
163 "could not probe {owner}/{repo} write access ({e}); verify the repo and token manually"
164 ),
165 );
166 }
167 };
168 probe_to_push_check(
169 github_repo_probe(&client, url, token, policy, log),
170 owner,
171 repo,
172 outcomes,
173 strict,
174 )
175}
176
177pub fn indeterminate_check(strict: bool, msg: String) -> PreflightCheck {
182 if strict {
183 PreflightCheck::Blocker(msg)
184 } else {
185 PreflightCheck::Warning(msg)
186 }
187}
188
189pub fn probe_to_push_check(
192 probe: RepoProbe,
193 owner: &str,
194 repo: &str,
195 outcomes: RepoAccessOutcomes,
196 strict: bool,
197) -> PreflightCheck {
198 match probe {
199 RepoProbe::Body(body) => match serde_json::from_str::<serde_json::Value>(&body) {
200 Ok(v) => match v.pointer("/permissions/push").and_then(|p| p.as_bool()) {
201 Some(true) => PreflightCheck::Pass,
202 Some(false) => outcomes.push_denied,
203 None => indeterminate_check(
204 strict,
205 format!(
206 "could not determine push access to {owner}/{repo} (no permissions in API \
207 response); verify the token scope manually"
208 ),
209 ),
210 },
211 Err(_) => indeterminate_check(
212 strict,
213 format!(
214 "could not parse {owner}/{repo} API response; verify the repo and token manually"
215 ),
216 ),
217 },
218 RepoProbe::Missing | RepoProbe::AuthDenied => outcomes.missing_or_denied,
219 RepoProbe::RateLimited => indeterminate_check(
224 strict,
225 format!(
226 "GitHub API rate-limited while probing {owner}/{repo}; could not verify write \
227 access — verify the repo and token manually"
228 ),
229 ),
230 RepoProbe::Inconclusive(reason) => indeterminate_check(
231 strict,
232 format!(
233 "could not probe {owner}/{repo} write access ({reason}); verify the repo and token manually"
234 ),
235 ),
236 }
237}
238
239pub fn github_repo_probe(
249 client: &reqwest::blocking::Client,
250 url: &str,
251 token: Option<&str>,
252 policy: &RetryPolicy,
253 log: &StageLogger,
254) -> RepoProbe {
255 let rlog = RetryLog::new("github repo probe", log);
256 let token = token.map(str::to_string);
257 let outcome = retry_sync(rlog, policy, |_attempt| {
258 let mut b = client
259 .get(url)
260 .header("Accept", "application/vnd.github+json")
261 .header("X-GitHub-Api-Version", "2022-11-28");
262 if let Some(ref tok) = token
263 && !tok.is_empty()
264 {
265 b = b.header("Authorization", format!("Bearer {tok}"));
266 }
267 match b.send() {
268 Ok(resp) => {
269 let code = resp.status().as_u16();
270 let rate_limited = response_is_rate_limited(resp.headers());
273 if resp.status().is_success() {
274 Ok(RepoProbe::Body(resp.text().unwrap_or_default()))
275 } else if resp.status().is_server_error() {
276 Err(ControlFlow::Continue(RepoProbe::Inconclusive(format!(
277 "HTTP {code}"
278 ))))
279 } else if code == 429 || ((code == 403 || code == 401) && rate_limited) {
280 Ok(RepoProbe::RateLimited)
281 } else if code == 404 {
282 Ok(RepoProbe::Missing)
283 } else if code == 403 || code == 401 {
284 Ok(RepoProbe::AuthDenied)
285 } else {
286 Ok(RepoProbe::Inconclusive(format!("unexpected HTTP {code}")))
287 }
288 }
289 Err(e) => {
290 let msg = format!("network failure: {e}");
291 if is_retriable(&e) {
292 Err(ControlFlow::Continue(RepoProbe::Inconclusive(msg)))
293 } else {
294 Err(ControlFlow::Break(RepoProbe::Inconclusive(msg)))
295 }
296 }
297 }
298 });
299 match outcome {
302 Ok(p) | Err(p) => p,
303 }
304}
305
306#[cfg(test)]
307mod push_check_tests {
308 use super::*;
313
314 fn outcomes() -> RepoAccessOutcomes {
315 RepoAccessOutcomes {
316 push_denied: PreflightCheck::Blocker("push denied".into()),
317 missing_or_denied: PreflightCheck::Blocker("missing or denied".into()),
318 }
319 }
320
321 #[test]
322 fn push_true_passes() {
323 let probe = RepoProbe::Body(r#"{"permissions":{"push":true}}"#.into());
324 assert_eq!(
325 probe_to_push_check(probe, "o", "r", outcomes(), false),
326 PreflightCheck::Pass
327 );
328 }
329
330 #[test]
331 fn push_false_returns_caller_push_denied() {
332 let probe = RepoProbe::Body(r#"{"permissions":{"push":false}}"#.into());
333 assert_eq!(
334 probe_to_push_check(probe, "o", "r", outcomes(), false),
335 PreflightCheck::Blocker("push denied".into())
336 );
337 }
338
339 #[test]
340 fn permissions_absent_warns() {
341 let probe = RepoProbe::Body(r#"{"full_name":"o/r"}"#.into());
342 match probe_to_push_check(probe, "o", "r", outcomes(), false) {
343 PreflightCheck::Warning(msg) => {
344 assert!(msg.contains("could not determine push access"), "{msg}")
345 }
346 other => panic!("expected Warning, got {other:?}"),
347 }
348 }
349
350 #[test]
351 fn unparsable_body_warns() {
352 let probe = RepoProbe::Body("not json".into());
353 match probe_to_push_check(probe, "o", "r", outcomes(), false) {
354 PreflightCheck::Warning(msg) => {
355 assert!(msg.contains("could not parse o/r"), "{msg}")
356 }
357 other => panic!("expected Warning, got {other:?}"),
358 }
359 }
360
361 #[test]
362 fn missing_and_auth_denied_return_caller_outcome() {
363 for probe in [RepoProbe::Missing, RepoProbe::AuthDenied] {
364 assert_eq!(
365 probe_to_push_check(probe, "o", "r", outcomes(), false),
366 PreflightCheck::Blocker("missing or denied".into())
367 );
368 }
369 }
370
371 #[test]
372 fn rate_limited_warns_never_escalates() {
373 match probe_to_push_check(RepoProbe::RateLimited, "o", "r", outcomes(), false) {
374 PreflightCheck::Warning(msg) => assert!(msg.contains("rate-limited"), "{msg}"),
375 other => panic!("expected Warning, got {other:?}"),
376 }
377 }
378
379 #[test]
380 fn strict_promotes_indeterminate_arms_to_blocker() {
381 for probe in [
382 RepoProbe::RateLimited,
383 RepoProbe::Inconclusive("HTTP 500".into()),
384 RepoProbe::Body(r#"{"full_name":"o/r"}"#.into()),
385 RepoProbe::Body("not json".into()),
386 ] {
387 match probe_to_push_check(probe, "o", "r", outcomes(), true) {
388 PreflightCheck::Blocker(_) => {}
389 other => panic!("strict must promote indeterminate to Blocker, got {other:?}"),
390 }
391 }
392 }
393
394 #[test]
395 fn strict_leaves_definitive_arms_unchanged() {
396 let probe = RepoProbe::Body(r#"{"permissions":{"push":true}}"#.into());
399 assert_eq!(
400 probe_to_push_check(probe, "o", "r", outcomes(), true),
401 PreflightCheck::Pass
402 );
403 assert_eq!(
404 probe_to_push_check(RepoProbe::Missing, "o", "r", outcomes(), true),
405 PreflightCheck::Blocker("missing or denied".into())
406 );
407 }
408
409 #[test]
410 fn inconclusive_warns_with_reason() {
411 let probe = RepoProbe::Inconclusive("HTTP 500".into());
412 match probe_to_push_check(probe, "o", "r", outcomes(), false) {
413 PreflightCheck::Warning(msg) => assert!(msg.contains("HTTP 500"), "{msg}"),
414 other => panic!("expected Warning, got {other:?}"),
415 }
416 }
417}
418
419#[cfg(test)]
420mod rate_limit_signature_tests {
421 use super::*;
422
423 #[test]
424 fn secondary_matches_message_or_doc_url_on_403_and_429() {
425 for status in [403u16, 429] {
426 assert!(is_secondary_rate_limit_signature(
427 status,
428 "You have exceeded a secondary rate limit",
429 None
430 ));
431 assert!(is_secondary_rate_limit_signature(
432 status,
433 "blocked",
434 Some("https://docs.github.com/rest/overview#secondary-rate-limits")
435 ));
436 }
437 }
438
439 #[test]
440 fn secondary_rejects_other_statuses_and_plain_403() {
441 assert!(!is_secondary_rate_limit_signature(
442 500,
443 "secondary rate limit",
444 None
445 ));
446 assert!(!is_secondary_rate_limit_signature(
447 403,
448 "Bad credentials",
449 Some("https://docs.github.com/rest")
450 ));
451 }
452
453 #[test]
454 fn rate_limit_signature_accepts_any_429() {
455 assert!(is_rate_limit_signature(429, "", None));
456 }
457
458 #[test]
459 fn rate_limit_signature_needs_body_signal_on_403() {
460 assert!(is_rate_limit_signature(
461 403,
462 "API rate limit exceeded for user ID 1",
463 None
464 ));
465 assert!(is_rate_limit_signature(
466 403,
467 "forbidden",
468 Some("https://docs.github.com/rest/overview/rate-limits-for-the-rest-api")
469 ));
470 assert!(!is_rate_limit_signature(
473 403,
474 "Resource not accessible by integration",
475 Some("https://docs.github.com/rest")
476 ));
477 assert!(!is_rate_limit_signature(401, "rate limit", None));
478 }
479}