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::retry::{RetryPolicy, is_retriable, retry_sync};
18
19/// Timeout for a single `GET /repos/{owner}/{repo}` preflight probe request.
20/// Shared by every probe caller so the release and publish preflights place
21/// the same bound on how long an unreachable GitHub can stall a run.
22pub const REPO_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
23
24/// Terminal classification of a single `GET /repos/{owner}/{repo}` probe,
25/// carrying enough to distinguish a transient rate-limit 403 from an auth 403.
26pub enum RepoProbe {
27    /// 2xx — carries the response body for `permissions.push` inspection.
28    Body(String),
29    /// 404 — repo missing under an otherwise-good token.
30    Missing,
31    /// 401 / 403 with NO rate-limit signal — the token cannot access the repo.
32    AuthDenied,
33    /// 429, or a 401 / 403 carrying a rate-limit signal (GitHub returns 403 for
34    /// both secondary-rate-limit and auth denial, distinguishable only by the
35    /// `Retry-After` / `X-RateLimit-Remaining: 0` headers) — transient.
36    RateLimited,
37    /// 5xx, an unexpected status, or a transport failure — verdict unknown.
38    Inconclusive(String),
39}
40
41/// Whether a GitHub response's headers mark it as rate-limited: a `Retry-After`
42/// header (primary or secondary limit) or `X-RateLimit-Remaining: 0`. Header
43/// lookups are case-insensitive ([`reqwest::header::HeaderMap`]).
44pub 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
55/// Whether an error body matches GitHub's *secondary* rate-limit signature:
56/// a 403 / 429 whose `message` mentions "secondary rate limit"
57/// (case-insensitive) or whose `documentation_url` points at
58/// `secondary-rate-limits`.
59///
60/// Body-signature detection exists for callers whose HTTP layer does not
61/// surface response headers (octocrab's `GitHubError` carries only
62/// message / documentation_url / status). Callers that do hold the headers
63/// should prefer [`response_is_rate_limited`]. Keeping the signature strings
64/// here — next to the header detector — means a GitHub body rewording is a
65/// one-place fix for every surface that discriminates 403s.
66pub 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
80/// Whether an error body marks the response as rate-limited at all
81/// (primary quota exhaustion or a secondary limit): any 429, or a 403 whose
82/// `message` / `documentation_url` carries a rate-limit signal.
83///
84/// A 403 with NO rate-limit signal is auth denial — the same default
85/// [`github_repo_probe`] applies to header-carrying probes — so callers must
86/// fast-fail those instead of sleeping through a rate-limit backoff.
87pub 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
102/// The two outcomes whose severity + wording genuinely differ between the
103/// preflight callers of [`github_repo_push_check`]: an unwritable repo blocks
104/// the required github-release target but only warns for a tap/index repo,
105/// and the missing/denied wording names what the caller was probing.
106///
107/// Every other arm of the probe→check mapping (the `permissions.push` parse
108/// ladder, the rate-limited / inconclusive / client-build warnings) is shared
109/// policy and lives in the mapper itself, so the two preflights cannot drift
110/// apart on how the same token+repo is classified.
111pub struct RepoAccessOutcomes {
112    /// Returned when the probe proves `permissions.push == false`.
113    pub push_denied: PreflightCheck,
114    /// Returned when the repo 404s or the token is denied access.
115    pub missing_or_denied: PreflightCheck,
116}
117
118/// Probe `GET {url}` and map the outcome onto a [`PreflightCheck`].
119///
120/// Builds the probe client, runs [`github_repo_probe`], and classifies:
121///
122/// * 200 + `permissions.push == true` ⇒ `Pass`
123/// * 200 + `permissions.push == false` ⇒ `outcomes.push_denied`
124/// * 200 + `permissions` absent / unparsable body ⇒ `Warning`
125/// * 404, or 401 / 403 without a rate-limit signal ⇒ `outcomes.missing_or_denied`
126/// * 429, or 401 / 403 carrying a rate-limit header ⇒ `Warning` (a transient
127///   GitHub rate limit must not abort a release that would otherwise succeed)
128/// * 5xx / transport failure / unexpected status ⇒ `Warning`
129pub 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
153/// Pure probe→check mapper backing [`github_repo_push_check`], split out so
154/// the classification arms are unit-testable without an HTTP responder.
155pub 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        // A secondary-rate-limit 403 is indistinguishable from auth denial by
177        // status alone; the headers prove it transient, so warn rather than
178        // abort a release whose token is actually fine.
179        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
189/// Run the `GET /repos/{owner}/{repo}` request under the shallow probe policy,
190/// reading response headers (not just the status) so a secondary-rate-limit 403
191/// is separable from an auth 403. 5xx and retriable transport errors retry
192/// within `policy`; everything else resolves on the first response.
193///
194/// `token` is optional: a `Some(non-empty)` value adds the `Authorization`
195/// bearer header (an empty string is treated as no token — the unauthenticated
196/// read path), so the required-token callers pass `Some(token)` and the
197/// best-effort callers can pass `None`.
198pub 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                // Capture the rate-limit verdict from headers BEFORE `text()`
219                // consumes the response.
220                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    // Both the success and the retries-exhausted arm collapse to the same
248    // terminal `RepoProbe`.
249    match outcome {
250        Ok(p) | Err(p) => p,
251    }
252}
253
254#[cfg(test)]
255mod push_check_tests {
256    //! The pure probe→check mapping arms. The HTTP-level probe behavior
257    //! (status/header classification) is pinned by the scripted-responder
258    //! tests at the two preflight call sites; these cover the shared
259    //! severity/parse policy that must not drift between them.
260    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        // The 403-without-signal case IS auth denial: sleeping through a
389        // rate-limit backoff on it hides a hard token failure.
390        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}