Skip to main content

anodizer_core/git/
github_probe.rs

1//! Shared GitHub `GET /repos/{owner}/{repo}` reachability + permission probe.
2//!
3//! Both the publish-stage tap/index preflights and the release-stage
4//! github-release preflight need the same network probe: issue the request
5//! under the shallow retry policy, read the rate-limit headers (not just the
6//! status, so a secondary-rate-limit 403 is separable from an auth 403), and
7//! classify the outcome. Only the *severity mapping* of two outcomes differs
8//! between callers — a tap that cannot be pushed is a `Warning`, whereas the
9//! required github-release target is a `Blocker` — so
10//! [`github_repo_push_check`] owns the whole probe→[`PreflightCheck`] mapping
11//! (including the `permissions.push` body parse) and each caller supplies
12//! only its [`RepoAccessOutcomes`].
13
14use std::ops::ControlFlow;
15
16use crate::PreflightCheck;
17use crate::log::StageLogger;
18use crate::retry::{RetryLog, RetryPolicy, is_retriable, retry_sync};
19
20/// Timeout for a single `GET /repos/{owner}/{repo}` preflight probe request.
21/// Shared by every probe caller so the release and publish preflights place
22/// the same bound on how long an unreachable GitHub can stall a run.
23pub const REPO_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
24
25/// Terminal classification of a single `GET /repos/{owner}/{repo}` probe,
26/// carrying enough to distinguish a transient rate-limit 403 from an auth 403.
27pub enum RepoProbe {
28    /// 2xx — carries the response body for `permissions.push` inspection.
29    Body(String),
30    /// 404 — repo missing under an otherwise-good token.
31    Missing,
32    /// 401 / 403 with NO rate-limit signal — the token cannot access the repo.
33    AuthDenied,
34    /// 429, or a 401 / 403 carrying a rate-limit signal (GitHub returns 403 for
35    /// both secondary-rate-limit and auth denial, distinguishable only by the
36    /// `Retry-After` / `X-RateLimit-Remaining: 0` headers) — transient.
37    RateLimited,
38    /// 5xx, an unexpected status, or a transport failure — verdict unknown.
39    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
54/// Whether a GitHub response's headers mark it as rate-limited: a `Retry-After`
55/// header (primary or secondary limit) or `X-RateLimit-Remaining: 0`. Header
56/// lookups are case-insensitive ([`reqwest::header::HeaderMap`]).
57pub 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
68/// Whether an error body matches GitHub's *secondary* rate-limit signature:
69/// a 403 / 429 whose `message` mentions "secondary rate limit"
70/// (case-insensitive) or whose `documentation_url` points at
71/// `secondary-rate-limits`.
72///
73/// Body-signature detection exists for callers whose HTTP layer does not
74/// surface response headers (octocrab's `GitHubError` carries only
75/// message / documentation_url / status). Callers that do hold the headers
76/// should prefer [`response_is_rate_limited`]. Keeping the signature strings
77/// here — next to the header detector — means a GitHub body rewording is a
78/// one-place fix for every surface that discriminates 403s.
79pub 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
93/// Whether an error body marks the response as rate-limited at all
94/// (primary quota exhaustion or a secondary limit): any 429, or a 403 whose
95/// `message` / `documentation_url` carries a rate-limit signal.
96///
97/// A 403 with NO rate-limit signal is auth denial — the same default
98/// [`github_repo_probe`] applies to header-carrying probes — so callers must
99/// fast-fail those instead of sleeping through a rate-limit backoff.
100pub 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
115/// The two outcomes whose severity + wording genuinely differ between the
116/// preflight callers of [`github_repo_push_check`]: an unwritable repo blocks
117/// the required github-release target but only warns for a tap/index repo,
118/// and the missing/denied wording names what the caller was probing.
119///
120/// Every other arm of the probe→check mapping (the `permissions.push` parse
121/// ladder, the rate-limited / inconclusive / client-build indeterminates —
122/// warnings by default, blockers under strict preflight) is shared policy and
123/// lives in the mapper itself, so the two preflights cannot drift apart on
124/// how the same token+repo is classified.
125pub struct RepoAccessOutcomes {
126    /// Returned when the probe proves `permissions.push == false`.
127    pub push_denied: PreflightCheck,
128    /// Returned when the repo 404s or the token is denied access.
129    pub missing_or_denied: PreflightCheck,
130}
131
132/// Probe `GET {url}` and map the outcome onto a [`PreflightCheck`].
133///
134/// Builds the probe client, runs [`github_repo_probe`], and classifies:
135///
136/// * 200 + `permissions.push == true` ⇒ `Pass`
137/// * 200 + `permissions.push == false` ⇒ `outcomes.push_denied`
138/// * 200 + `permissions` absent / unparsable body ⇒ indeterminate —
139///   `Warning`, or `Blocker` when `strict`
140/// * 404, or 401 / 403 without a rate-limit signal ⇒ `outcomes.missing_or_denied`
141/// * 429, or 401 / 403 carrying a rate-limit header ⇒ indeterminate (a
142///   transient GitHub rate limit must not abort a release that would
143///   otherwise succeed) — `Warning`, or `Blocker` when `strict`
144/// * 5xx / transport failure / unexpected status ⇒ indeterminate — `Warning`,
145///   or `Blocker` when `strict`
146#[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
177/// Wrap an indeterminate probe outcome (the probe could not reach a verdict)
178/// in its effective severity: `Warning` by default so a transient upstream
179/// blip cannot abort an otherwise-valid release, `Blocker` under strict
180/// preflight (fail-closed).
181pub fn indeterminate_check(strict: bool, msg: String) -> PreflightCheck {
182    if strict {
183        PreflightCheck::Blocker(msg)
184    } else {
185        PreflightCheck::Warning(msg)
186    }
187}
188
189/// Pure probe→check mapper backing [`github_repo_push_check`], split out so
190/// the classification arms are unit-testable without an HTTP responder.
191pub 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        // A secondary-rate-limit 403 is indistinguishable from auth denial by
220        // status alone; the headers prove it transient, so warn (block only
221        // under strict preflight) rather than abort a release whose token is
222        // actually fine.
223        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
239/// Run the `GET /repos/{owner}/{repo}` request under the shallow probe policy,
240/// reading response headers (not just the status) so a secondary-rate-limit 403
241/// is separable from an auth 403. 5xx and retriable transport errors retry
242/// within `policy`; everything else resolves on the first response.
243///
244/// `token` is optional: a `Some(non-empty)` value adds the `Authorization`
245/// bearer header (an empty string is treated as no token — the unauthenticated
246/// read path), so the required-token callers pass `Some(token)` and the
247/// best-effort callers can pass `None`.
248pub 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                // Capture the rate-limit verdict from headers BEFORE `text()`
271                // consumes the response.
272                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    // Both the success and the retries-exhausted arm collapse to the same
300    // terminal `RepoProbe`.
301    match outcome {
302        Ok(p) | Err(p) => p,
303    }
304}
305
306#[cfg(test)]
307mod push_check_tests {
308    //! The pure probe→check mapping arms. The HTTP-level probe behavior
309    //! (status/header classification) is pinned by the scripted-responder
310    //! tests at the two preflight call sites; these cover the shared
311    //! severity/parse policy that must not drift between them.
312    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        // Definitive outcomes keep the caller-supplied severity — strict only
397        // touches the indeterminate arms.
398        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        // The 403-without-signal case IS auth denial: sleeping through a
471        // rate-limit backoff on it hides a hard token failure.
472        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}