Skip to main content

runner_manager_github/
lib.rs

1// owner: c2-device-flow-auth
2//
3// c2 also owns the shared authenticated HTTP client that lives at this crate
4// root; c3 owns `rest`, and c4 owns `demand` and `jit`.
5
6//! The GitHub gateway.
7//!
8//! This crate holds every line of code in the product that talks to GitHub, and
9//! the crate root holds the one client all of it goes through.
10//!
11//! * [`device_flow`] — the OAuth 2.0 Device Authorization Grant, which is the
12//!   *only* way this product ever obtains a credential (D3, D16).
13//! * [`AuthenticatedClient`] — the shared `api.github.com` client. Every request
14//!   in this crate is built by it, which is what makes "sets
15//!   `X-GitHub-Api-Version` and an explicit `Accept`" a property of the design
16//!   rather than of each call site, and what lets the authentication-failure
17//!   taxonomy be implemented exactly once.
18//! * [`rest`], [`demand`], [`jit`] — typed adapters owned by `c3` and `c4`,
19//!   built on [`AuthenticatedClient`].
20//!
21//! # Three properties this crate is required to keep
22//!
23//! **It holds no client secret, and it renews without one.** This paragraph
24//! used to say the opposite — that the App opts out of user-token expiration,
25//! that no renewal token is ever issued, and that renewing would require a
26//! client secret a public client cannot hold. All three were wrong, and a test
27//! in this crate enforced the error by forbidding the word "refresh token".
28//!
29//! The published App has user-token expiration **on**: a device-flow exchange
30//! returns `expires_in: 28800` and a `refresh_token`. GitHub requires the
31//! client secret to refresh *"unless the user access token was generated using
32//! the device flow"*, and every one of this product's is. So the credential
33//! renews itself, no server appears in the design, and the eight-hour life of
34//! an access token is invisible to a daemon that runs for months. See
35//! [`AuthenticatedClient::renew_once`], and
36//! `docs/spikes/token-expiry-and-renewal.md` for the two renewals that
37//! confirmed it on real hosts.
38//!
39//! [`AuthenticatedClient::revalidate`] is what still happens on a `401` for a
40//! credential with no refresh half — every one issued before 0.1.11.
41//!
42//! **It persists nothing.** [`device_flow::DeviceFlow::complete`] *returns* the
43//! token; it never writes it anywhere. The machine-scoped secret store is `d2`
44//! and the wiring is `f1`. That boundary is why this crate has no dependency on
45//! `runner-manager-platform` and performs no filesystem write outside its own
46//! tests — and it is what lets the whole gateway be tested with no platform
47//! dependency at all.
48//!
49//! **It never renders a secret.** The device code, the user access token, and
50//! every header carrying either are absent from `Debug`, from `Display`, from
51//! errors, and from tracing output. Every type here that holds one wraps it in
52//! [`secrecy::SecretString`] *and* implements [`fmt::Debug`] by hand, because a
53//! `#[derive(Debug)]` added later to a struct with a plain `String` field is
54//! precisely how this control is lost. `tests/no_secret_reaches_the_logs.rs`
55//! drives a whole login and an authenticated round trip through a capturing
56//! `tracing` subscriber and fails if any of the three appears.
57//!
58//! That scan is a **separate test binary**, and deliberately so. As a unit test
59//! it silently stopped working: `tracing` caches each callsite's `Interest`
60//! process-wide while `with_default` installs a subscriber on one *thread*, and
61//! run **concurrently** with the crate's other unit tests the scan captured
62//! only its own handful of events — passing with a real device-code leak on the
63//! live path. A binary holding one test has no concurrency to be poisoned by.
64//! The word "concurrently" is load-bearing and was measured;
65//! `tests/no_secret_reaches_the_logs.rs` records the numbers and what they rule
66//! out.
67
68pub mod demand;
69pub mod device_flow;
70pub mod jit;
71pub mod rest;
72
73use std::{
74    fmt,
75    sync::{
76        Arc,
77        atomic::{AtomicU64, Ordering},
78    },
79    time::Duration,
80};
81
82use chrono::{DateTime, Utc};
83use reqwest::{Method, StatusCode};
84use runner_manager_domain::model::{Clock, Org, OwnerRepo, Timestamp};
85
86/// Re-exported because [`GithubError::headers`] and [`ApiResponse::headers`]
87/// return one, and a consumer cannot *name* a type it has no path to.
88///
89/// `a1` owns every manifest in this workspace, so a crate outside this one that
90/// wanted to hold a `HeaderMap` from this seam would otherwise need `reqwest`
91/// added to its own dependencies — turning a `c2` seam into an `a1` change, and
92/// putting `reqwest`'s version in two places at once. [`GithubError::retry_after`]
93/// and [`GithubError::rate_limit`] exist precisely so that the common cases need
94/// no path at all; this is for the ones that do.
95///
96/// It is re-exported under its own name rather than an alias so that the type a
97/// consumer imports is the type the signatures already show.
98pub use reqwest::header::HeaderMap;
99use secrecy::{ExposeSecret, SecretString};
100use serde::{Deserialize, Serialize, de::DeserializeOwned};
101use url::Url;
102
103// ---------------------------------------------------------------------------
104// Constants
105// ---------------------------------------------------------------------------
106
107/// The REST API version every request pins.
108///
109/// `04-subsystem-contracts.md`: "All requests set `X-GitHub-Api-Version` and an
110/// explicit `Accept` header." Both spikes ran against this version.
111pub const GITHUB_API_VERSION: &str = "2022-11-28";
112
113/// The media type every request asks for, stated rather than defaulted.
114pub const GITHUB_ACCEPT: &str = "application/vnd.github+json";
115
116/// Production `api.github.com`.
117pub const GITHUB_API_BASE: &str = "https://api.github.com/";
118
119/// Production `github.com`, which hosts the device-flow endpoints. They are on
120/// the web host, not the API host.
121pub const GITHUB_WEB_BASE: &str = "https://github.com/";
122
123/// The canonical page a user types their code into.
124///
125/// This constant *is* the phishing control (`07-security.md`, threat table): the
126/// tool prints this URL and never proxies, embeds, or imitates the approval
127/// page. [`Endpoints::verification_url`] derives from the configured web base so
128/// a test server can be pointed at, and
129/// [`device_flow::DeviceAuthorization::verification_uri`] is checked against it
130/// so a response that tries to send the user somewhere else is rejected rather
131/// than displayed.
132pub const DEVICE_VERIFICATION_PATH: &str = "login/device";
133
134/// What a `401` re-validates the held credential against.
135///
136/// `GET /user/installations` rather than `GET /user`, because it is the call the
137/// D18 spike actually made with a user-to-server token and observed `200` from
138/// (`docs/spikes/d18-org-jit-verification.md`, "The permission that authorized
139/// it"), and because a successful re-validation then carries the same answer
140/// [`AuthenticatedClient::discover_installations`] needs.
141pub const REVALIDATION_PATH: &str = "/user/installations";
142
143/// How long a lockout backs off for when GitHub sends no `retry-after`.
144///
145/// `03-control-flows.md` flow 4.3 requires a back-off but names no duration.
146/// Sixty seconds is GitHub's own documented floor for its secondary rate limits.
147pub const DEFAULT_LOCKOUT_BACKOFF: Duration = Duration::from_secs(60);
148
149/// The longest a lockout may silence this client, whatever `Retry-After` said.
150///
151/// A back-off is a *safety* mechanism, and an unclamped one is a denial of
152/// service with extra steps: `Retry-After: 86400` would latch a silent
153/// twenty-four-hour outage of the agent's reconciliation loop, clearable only by
154/// [`AuthenticatedClient::clear_lockout`]. Fifteen minutes is far longer than any
155/// back-off GitHub documents for the authentication lockout this latches on, and
156/// short enough that a hostile or simply wrong header cannot take the product
157/// down for a shift. Honouring a header without a ceiling is trusting a remote
158/// party with the product's availability.
159pub const MAX_LOCKOUT_BACKOFF: Duration = Duration::from_secs(15 * 60);
160
161/// The most pages either pagination loop follows before giving up.
162///
163/// A `Link: rel="next"` that points back at the page it arrived on — a proxy
164/// rewriting the header, or a bug at the other end — is an infinite loop inside
165/// the agent's reconciliation loop, which is the one place in this product that
166/// must not be able to wedge. At `per_page=100` this ceiling is ten thousand
167/// installations or repositories, past any real account by orders of magnitude,
168/// so it bounds the pathological case without truncating a legitimate one.
169pub const MAX_PAGES: usize = 100;
170
171/// Per-request ceiling, so one wedged connection cannot stall the agent's
172/// reconciliation loop forever.
173pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
174
175/// The `User-Agent` GitHub requires on every API request.
176pub const USER_AGENT: &str = concat!("runner-manager/", env!("CARGO_PKG_VERSION"));
177
178// ---------------------------------------------------------------------------
179// The published App
180// ---------------------------------------------------------------------------
181
182/// The published GitHub App this product authenticates as (D3, D16).
183///
184/// Both fields are **public by design**. `07-security.md`'s credential inventory
185/// lists the `client_id` as "Not secret … may appear in logs and documentation",
186/// which is exactly what makes the device flow serverless: a public client
187/// cannot secure a client secret, and this design never tries to.
188///
189/// The concrete values are *not* compiled in here. Registering and publishing
190/// the App is Phase 0 of `06-migration-rollout.md` and has not happened, so
191/// there is no honest value to write; `f1` supplies both when it wires the CLI.
192/// Committing a placeholder that looked real would be worse than requiring the
193/// caller to pass one.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct AppRegistration {
196    client_id: String,
197    slug: String,
198}
199
200impl AppRegistration {
201    /// # Errors
202    /// An empty `client_id` or an empty `slug`.
203    pub fn new(client_id: impl Into<String>, slug: impl Into<String>) -> Result<Self, ConfigError> {
204        let client_id = client_id.into();
205        let slug = slug.into();
206        if client_id.trim().is_empty() {
207            return Err(ConfigError::Empty { what: "client_id" });
208        }
209        if slug.trim().is_empty() {
210            return Err(ConfigError::Empty { what: "app slug" });
211        }
212        Ok(Self { client_id, slug })
213    }
214
215    #[must_use]
216    pub fn client_id(&self) -> &str {
217        &self.client_id
218    }
219
220    #[must_use]
221    pub fn slug(&self) -> &str {
222        &self.slug
223    }
224
225    /// The canonical URL a user with no installation must visit.
226    ///
227    /// `03-control-flows.md` flow 1.1: "If the published App is not yet installed
228    /// on any repository, it prints the installation URL."
229    ///
230    /// # Panics
231    /// Never, for a registration built through [`AppRegistration::new`]: the slug
232    /// is non-empty and is percent-encoded into the path.
233    #[must_use]
234    pub fn install_url(&self, endpoints: &Endpoints) -> Url {
235        endpoints
236            .web_base
237            .join("apps/")
238            .and_then(|u| u.join(&format!("{}/", encode_path_segment(&self.slug))))
239            .and_then(|u| u.join("installations/new"))
240            .expect("a non-empty encoded slug always joins onto the web base")
241    }
242}
243
244fn encode_path_segment(raw: &str) -> String {
245    raw.chars()
246        .map(|c| {
247            if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~') {
248                c.to_string()
249            } else {
250                let mut buf = [0_u8; 4];
251                c.encode_utf8(&mut buf)
252                    .as_bytes()
253                    .iter()
254                    .map(|b| format!("%{b:02X}"))
255                    .collect()
256            }
257        })
258        .collect()
259}
260
261/// Where GitHub is.
262///
263/// Two bases rather than one, because the device grant lives on `github.com`
264/// while every API call lives on `api.github.com`. Tests point both at one
265/// `wiremock` server; the paths do not collide.
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct Endpoints {
268    api_base: Url,
269    web_base: Url,
270}
271
272impl Endpoints {
273    /// Production GitHub.
274    ///
275    /// # Panics
276    /// Never: both constants are parsed at every call and are valid URLs.
277    #[must_use]
278    pub fn production() -> Self {
279        Self {
280            api_base: Url::parse(GITHUB_API_BASE).expect("GITHUB_API_BASE is a valid URL"),
281            web_base: Url::parse(GITHUB_WEB_BASE).expect("GITHUB_WEB_BASE is a valid URL"),
282        }
283    }
284
285    /// Both bases are normalised to end in `/` so that relative joins keep the
286    /// whole base path instead of replacing its last segment.
287    #[must_use]
288    pub fn new(api_base: Url, web_base: Url) -> Self {
289        Self {
290            api_base: with_trailing_slash(api_base),
291            web_base: with_trailing_slash(web_base),
292        }
293    }
294
295    /// Point every endpoint at one test server.
296    ///
297    /// # Errors
298    /// `root` not being a parseable absolute URL.
299    pub fn for_test_server(root: &str) -> Result<Self, ConfigError> {
300        let root = Url::parse(root).map_err(|_| ConfigError::Empty {
301            what: "test server URL",
302        })?;
303        Ok(Self::new(root.clone(), root))
304    }
305
306    #[must_use]
307    pub fn api_base(&self) -> &Url {
308        &self.api_base
309    }
310
311    #[must_use]
312    pub fn web_base(&self) -> &Url {
313        &self.web_base
314    }
315
316    /// # Panics
317    /// Never: the path is a constant and the base ends in `/`.
318    #[must_use]
319    pub fn device_code_url(&self) -> Url {
320        self.web_base
321            .join("login/device/code")
322            .expect("a constant path joins onto a normalised base")
323    }
324
325    /// # Panics
326    /// Never: the path is a constant and the base ends in `/`.
327    #[must_use]
328    pub fn access_token_url(&self) -> Url {
329        self.web_base
330            .join("login/oauth/access_token")
331            .expect("a constant path joins onto a normalised base")
332    }
333
334    /// The canonical page the user code is typed into, and the only device-flow
335    /// URL this product ever prints.
336    ///
337    /// # Panics
338    /// Never: the path is a constant and the base ends in `/`.
339    #[must_use]
340    pub fn verification_url(&self) -> Url {
341        self.web_base
342            .join(DEVICE_VERIFICATION_PATH)
343            .expect("a constant path joins onto a normalised base")
344    }
345}
346
347impl Default for Endpoints {
348    fn default() -> Self {
349        Self::production()
350    }
351}
352
353fn with_trailing_slash(mut url: Url) -> Url {
354    if !url.path().ends_with('/') {
355        let path = format!("{}/", url.path());
356        url.set_path(&path);
357    }
358    url
359}
360
361/// A configuration value this crate refuses to start with.
362#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
363pub enum ConfigError {
364    #[error("{what} must not be empty")]
365    Empty { what: &'static str },
366}
367
368// ---------------------------------------------------------------------------
369// The credential
370// ---------------------------------------------------------------------------
371
372/// A user access token obtained from the device flow.
373///
374/// **Non-expiring, and non-renewable.** The published App opts out of
375/// user-token expiration, so GitHub issues no renewal token alongside this one
376/// and there is nothing to renew — see the crate documentation. The token is
377/// invalidated only by the user uninstalling the App or revoking the
378/// authorization at GitHub.
379///
380/// `Debug` is written by hand. Deriving it here would put the token into every
381/// `tracing` field, every `unwrap()` panic message, and every `anyhow` chain
382/// that ever carries one, which is the exact leak `07-security.md` gates on.
383#[derive(Clone)]
384pub struct UserAccessToken {
385    token: SecretString,
386    token_type: String,
387    scope: Option<String>,
388    /// The renewal half, when the App issues one.
389    ///
390    /// # Why this is an `Option` rather than a second type
391    ///
392    /// Whether a credential can renew itself is a setting on the *App*, not a
393    /// property of this build: an App with user-token expiration off returns an
394    /// access token and nothing else, and one with it on returns a pair. Both
395    /// shapes reach this type, and a host holding either must keep working --
396    /// otherwise flipping that setting would strand every installation that had
397    /// not upgraded, which for a published product is an outage nobody asked
398    /// for.
399    ///
400    /// `None` is therefore not a defect. It is the credential this product held
401    /// for its whole life until now: non-expiring, unrenewable, replaced only
402    /// by an interactive `auth login`.
403    renewal: Option<Renewal>,
404}
405
406/// What a token needs in order to replace itself without a person.
407#[derive(Clone)]
408pub struct Renewal {
409    refresh_token: SecretString,
410    /// When the access token stops being accepted, if it was stated.
411    pub access_expires_at: Option<DateTime<Utc>>,
412    /// When the *refresh* token stops working. Past this, only an interactive
413    /// sign-in helps -- and it is six months from the last renewal, not from
414    /// the first sign-in, so a host that runs at all never reaches it.
415    pub refresh_expires_at: Option<DateTime<Utc>>,
416}
417
418impl Renewal {
419    /// The refresh token itself. Kept behind a method for the same reason the
420    /// access token is: it is the more dangerous half of the pair, because it
421    /// mints access tokens indefinitely.
422    #[must_use]
423    pub fn refresh_token(&self) -> &SecretString {
424        &self.refresh_token
425    }
426}
427
428impl fmt::Debug for Renewal {
429    /// Written by hand, like [`UserAccessToken`]'s, and for a stronger reason:
430    /// a leaked refresh token does not expire in eight hours.
431    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
432        f.debug_struct("Renewal")
433            .field("refresh_token", &"[redacted]")
434            .field("access_expires_at", &self.access_expires_at)
435            .field("refresh_expires_at", &self.refresh_expires_at)
436            .finish()
437    }
438}
439
440impl UserAccessToken {
441    #[must_use]
442    pub fn new(token: SecretString) -> Self {
443        Self {
444            token,
445            token_type: "bearer".to_string(),
446            scope: None,
447            renewal: None,
448        }
449    }
450
451    /// The whole credential as the token endpoint returned it. `pub(crate)`
452    /// because [`device_flow`] is the only thing in the product entitled to mint
453    /// one — every other path receives a token rather than constructing it.
454    pub(crate) fn from_parts(
455        token: SecretString,
456        token_type: String,
457        scope: Option<String>,
458    ) -> Self {
459        Self {
460            token,
461            token_type,
462            scope,
463            renewal: None,
464        }
465    }
466
467    /// Attach the renewal half, if the App issued one.
468    ///
469    /// The two durations are seconds-from-now as GitHub states them, turned
470    /// into instants here so that nothing downstream has to remember when
471    /// "now" was.
472    #[must_use]
473    pub(crate) fn with_renewal(
474        mut self,
475        refresh_token: Option<SecretString>,
476        access_expires_in: Option<u64>,
477        refresh_expires_in: Option<u64>,
478    ) -> Self {
479        self.renewal = refresh_token.map(|refresh_token| {
480            let at = |secs: Option<u64>| {
481                secs.and_then(|s| i64::try_from(s).ok())
482                    .and_then(|s| Utc::now().checked_add_signed(chrono::TimeDelta::seconds(s)))
483            };
484            Renewal {
485                refresh_token,
486                access_expires_at: at(access_expires_in),
487                refresh_expires_at: at(refresh_expires_in),
488            }
489        });
490        self
491    }
492
493    /// The renewal half, when there is one.
494    #[must_use]
495    pub fn renewal(&self) -> Option<&Renewal> {
496        self.renewal.as_ref()
497    }
498
499    /// Rebuild the credential `d2` handed back, for `f1`.
500    #[must_use]
501    pub fn from_stored(token: SecretString) -> Self {
502        Self::from_stored_document(&token)
503    }
504
505    /// Reads whichever of the two stored shapes is there.
506    ///
507    /// # Why the store holds a document now, and why the old shape still loads
508    ///
509    /// A renewable credential is three values -- access token, refresh token,
510    /// and when each stops working -- where there used to be one string. The
511    /// secret store takes one opaque value per host, so the document goes
512    /// inside it rather than the store growing a schema: no platform change, no
513    /// migration step, and the same DPAPI blob or keychain item as before.
514    ///
515    /// **A value that is not this document is a bare access token**, which is
516    /// what every host stored until now. That is not a fallback for tidiness:
517    /// upgrading must not log anybody out, and the App's expiration setting can
518    /// be turned on -- or back off -- without stranding hosts that are mid-way
519    /// through either. A token has no internal structure to confuse with JSON,
520    /// so the discrimination is unambiguous.
521    #[must_use]
522    pub fn from_stored_document(stored: &SecretString) -> Self {
523        #[derive(Deserialize)]
524        struct Document {
525            access_token: String,
526            #[serde(default)]
527            refresh_token: Option<String>,
528            #[serde(default)]
529            access_expires_at: Option<DateTime<Utc>>,
530            #[serde(default)]
531            refresh_expires_at: Option<DateTime<Utc>>,
532        }
533
534        match serde_json::from_str::<Document>(stored.expose_secret()) {
535            Ok(document) => Self {
536                token: SecretString::from(document.access_token),
537                token_type: "bearer".to_string(),
538                scope: None,
539                renewal: document.refresh_token.map(|refresh_token| Renewal {
540                    refresh_token: SecretString::from(refresh_token),
541                    access_expires_at: document.access_expires_at,
542                    refresh_expires_at: document.refresh_expires_at,
543                }),
544            },
545            Err(_) => Self::new(stored.clone()),
546        }
547    }
548
549    /// The value to hand the secret store.
550    ///
551    /// Always the document, even for a credential with no renewal half: one
552    /// shape written means one shape to reason about, and reading still accepts
553    /// the bare token that older versions wrote.
554    #[must_use]
555    pub fn to_stored_document(&self) -> SecretString {
556        #[derive(Serialize)]
557        struct Document<'a> {
558            access_token: &'a str,
559            #[serde(skip_serializing_if = "Option::is_none")]
560            refresh_token: Option<&'a str>,
561            #[serde(skip_serializing_if = "Option::is_none")]
562            access_expires_at: Option<DateTime<Utc>>,
563            #[serde(skip_serializing_if = "Option::is_none")]
564            refresh_expires_at: Option<DateTime<Utc>>,
565        }
566
567        let document = Document {
568            access_token: self.token.expose_secret(),
569            refresh_token: self
570                .renewal
571                .as_ref()
572                .map(|r| r.refresh_token.expose_secret()),
573            access_expires_at: self.renewal.as_ref().and_then(|r| r.access_expires_at),
574            refresh_expires_at: self.renewal.as_ref().and_then(|r| r.refresh_expires_at),
575        };
576        // Serialising a struct of `&str` cannot fail; the fallback keeps the
577        // access token usable rather than inventing an error path nobody can
578        // act on.
579        SecretString::from(
580            serde_json::to_string(&document)
581                .unwrap_or_else(|_| self.token.expose_secret().to_string()),
582        )
583    }
584
585    /// The token itself. Every call site of this is a place a secret can escape,
586    /// so there are deliberately few: the `Authorization` header, and `d2`'s
587    /// store call.
588    #[must_use]
589    pub fn secret(&self) -> &SecretString {
590        &self.token
591    }
592
593    #[must_use]
594    pub fn token_type(&self) -> &str {
595        &self.token_type
596    }
597
598    #[must_use]
599    pub fn scope(&self) -> Option<&str> {
600        self.scope.as_deref()
601    }
602
603    /// The token's four-character family prefix — `ghu_` for an App
604    /// user-to-server token — and nothing else.
605    ///
606    /// This exists so diagnostics can answer "did the device flow return the
607    /// kind of token we expected?" without exposing the token. The D17 spike
608    /// asserted exactly this and no more.
609    #[must_use]
610    pub fn family(&self) -> &str {
611        let raw = self.token.expose_secret();
612        match raw.find('_') {
613            Some(idx) if idx < 8 => &raw[..=idx],
614            _ => "",
615        }
616    }
617
618    /// `true` for the `ghu_` family the published App issues.
619    #[must_use]
620    pub fn is_user_to_server(&self) -> bool {
621        self.family() == "ghu_"
622    }
623}
624
625impl fmt::Debug for UserAccessToken {
626    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
627        f.debug_struct("UserAccessToken")
628            .field("token", &"[REDACTED]")
629            .field("family", &self.family())
630            .finish_non_exhaustive()
631    }
632}
633
634impl PartialEq for UserAccessToken {
635    /// Equality exists so [`device_flow::PollOutcome`] can carry a token and
636    /// still be compared in a test. Production code never compares two
637    /// credentials, and this is not a constant-time comparison.
638    fn eq(&self, other: &Self) -> bool {
639        self.token.expose_secret() == other.token.expose_secret()
640            && self.token_type == other.token_type
641            && self.scope == other.scope
642    }
643}
644
645impl Eq for UserAccessToken {}
646
647// ---------------------------------------------------------------------------
648// Errors
649// ---------------------------------------------------------------------------
650
651/// Everything [`AuthenticatedClient`] can fail with.
652///
653/// The first three variants are the taxonomy `03-control-flows.md` flow 4.3
654/// requires, and they are separate variants because `c3` and `f1` both act on
655/// the distinction: [`GithubError::AuthenticationFailed`] moves a policy to
656/// `authentication_failed` and tells the operator to run `auth login`,
657/// [`GithubError::AuthenticationLockout`] must *wait* and tell the operator
658/// nothing is wrong with their credential, and [`GithubError::Forbidden`] is a
659/// permissions answer that re-authenticating will not change.
660///
661/// # Why the failing variants carry response headers
662///
663/// Rate-limit *policy* is `c3`'s and is deliberately not implemented here. But a
664/// policy needs evidence, and the evidence — `retry-after`,
665/// `x-ratelimit-remaining`, `x-ratelimit-reset` — only exists on the response
666/// that failed. An error taxonomy that dropped those headers would leave `c3`
667/// with no way to honour a `429` except by editing this file, which is exactly
668/// the conflict the `c2`/`c3` ownership split exists to prevent. So
669/// [`GithubError::Status`] and [`GithubError::Forbidden`] carry the headers
670/// verbatim and interpret none of them; see [`GithubError::headers`].
671#[derive(Debug, thiserror::Error)]
672pub enum GithubError {
673    /// GitHub rejected the credential and a single re-validation confirmed it.
674    /// Terminal: only an interactive `auth login` clears this.
675    #[error(
676        "GitHub rejected the stored credential; run `runner-manager auth login` to sign in again"
677    )]
678    AuthenticationFailed,
679
680    /// GitHub answered `403` after `401`s — its temporary authentication
681    /// lockout, not a permissions change. Back off; do not re-authenticate and
682    /// do not retry.
683    #[error(
684        "GitHub has temporarily locked out authentication for this credential; \
685         back off for {}s and do not retry — the credential itself is not the problem",
686        retry_after.as_secs()
687    )]
688    AuthenticationLockout { retry_after: Duration },
689
690    /// A `403` that is not the lockout: a permissions answer, or GitHub's own
691    /// rate limit. `c3` tells the two apart from `headers`; this crate does not,
692    /// because which of them is worth retrying is rate-limit policy.
693    #[error(
694        "GitHub denied {method} {path}: the App installation does not grant it{}",
695        message.as_deref().map(|m| format!(" ({m})")).unwrap_or_default()
696    )]
697    Forbidden {
698        method: String,
699        path: String,
700        message: Option<String>,
701        /// The response headers, verbatim and uninterpreted.
702        headers: Box<HeaderMap>,
703    },
704
705    #[error(
706        "GitHub returned {status} for {method} {path}{}",
707        message.as_deref().map(|m| format!(": {m}")).unwrap_or_default()
708    )]
709    Status {
710        status: u16,
711        method: String,
712        path: String,
713        message: Option<String>,
714        /// The response headers, verbatim and uninterpreted. A `429` reaches
715        /// `c3` through this variant, and its `retry-after` survives with it.
716        headers: Box<HeaderMap>,
717    },
718
719    /// The request never got an answer. The URL is stripped from the source
720    /// error before it is stored: a device-flow URL never carries a secret, but
721    /// stripping it costs nothing and removes a whole class of future leak.
722    #[error("GitHub was unreachable")]
723    Transport(#[source] reqwest::Error),
724
725    #[error("a {what} response from GitHub could not be decoded as {expected}")]
726    Decode {
727        what: &'static str,
728        expected: &'static str,
729        #[source]
730        source: serde_json::Error,
731    },
732
733    #[error("GitHub returned {value:?} for {what}, which this client cannot use")]
734    Malformed { what: &'static str, value: String },
735
736    #[error(transparent)]
737    Config(#[from] ConfigError),
738}
739
740impl GithubError {
741    /// `true` for the two authentication outcomes, which callers handle
742    /// differently from every other failure.
743    #[must_use]
744    pub fn is_authentication(&self) -> bool {
745        matches!(
746            self,
747            Self::AuthenticationFailed | Self::AuthenticationLockout { .. }
748        )
749    }
750
751    /// `true` only for the lockout, which is the one authentication outcome that
752    /// resolves by waiting rather than by signing in again.
753    #[must_use]
754    pub fn is_lockout(&self) -> bool {
755        matches!(self, Self::AuthenticationLockout { .. })
756    }
757
758    /// The failing response's headers, for the variants that have them.
759    ///
760    /// This is the whole of `c2`'s contribution to rate limiting: it hands `c3`
761    /// the evidence and stops there. Nothing in this crate reads
762    /// `x-ratelimit-remaining` to decide anything.
763    #[must_use]
764    pub fn headers(&self) -> Option<&HeaderMap> {
765        match self {
766            Self::Status { headers, .. } | Self::Forbidden { headers, .. } => Some(headers),
767            _ => None,
768        }
769    }
770
771    /// The failing response's `retry-after`, in seconds, if it sent one.
772    ///
773    /// Reading a documented header is evidence, not policy: what to *do* with a
774    /// `retry-after` — wait, shed load, surface it to an operator — is `c3`'s.
775    #[must_use]
776    pub fn retry_after(&self) -> Option<Duration> {
777        self.headers().and_then(retry_after)
778    }
779
780    /// The `x-ratelimit-remaining` / `x-ratelimit-reset` pair, when present.
781    ///
782    /// Returned as the raw numbers GitHub sent. `reset` is a Unix timestamp in
783    /// seconds, which is what the header carries; it is deliberately not turned
784    /// into a [`Timestamp`] here, because comparing it against a clock is the
785    /// first step of a policy decision and that decision is `c3`'s.
786    #[must_use]
787    pub fn rate_limit(&self) -> Option<RateLimitEvidence> {
788        let headers = self.headers()?;
789        let read = |name: &str| {
790            headers
791                .get(name)
792                .and_then(|v| v.to_str().ok())
793                .and_then(|v| v.trim().parse::<u64>().ok())
794        };
795        let remaining = read("x-ratelimit-remaining");
796        let reset = read("x-ratelimit-reset");
797        if remaining.is_none() && reset.is_none() {
798            return None;
799        }
800        Some(RateLimitEvidence {
801            remaining,
802            reset_unix_secs: reset,
803            retry_after: self.retry_after(),
804        })
805    }
806}
807
808/// What GitHub said about its own rate limit on a response that failed.
809///
810/// Evidence, carried across the `c2`/`c3` seam. Every field is what the wire
811/// said, and none of them has been interpreted.
812#[derive(Debug, Clone, Copy, PartialEq, Eq)]
813pub struct RateLimitEvidence {
814    /// `x-ratelimit-remaining`. Zero is GitHub's primary rate limit.
815    pub remaining: Option<u64>,
816    /// `x-ratelimit-reset`, a Unix timestamp in seconds.
817    pub reset_unix_secs: Option<u64>,
818    /// `retry-after`, which secondary rate limits send instead.
819    pub retry_after: Option<Duration>,
820}
821
822fn transport(err: reqwest::Error) -> GithubError {
823    GithubError::Transport(err.without_url())
824}
825
826// ---------------------------------------------------------------------------
827// Requests and responses
828// ---------------------------------------------------------------------------
829
830/// One `api.github.com` request, before authentication headers are applied.
831///
832/// `Debug` is written by hand and never renders the body: `c4` posts
833/// `generate-jitconfig` requests through this type, and a JIT configuration is
834/// a sensitive short-lived value (`07-security.md`, credential inventory).
835#[derive(Clone)]
836pub struct ApiRequest {
837    method: Method,
838    /// Either a path relative to the API base, or an absolute URL — which is
839    /// what a `Link: rel="next"` page is.
840    path: String,
841    query: Vec<(String, String)>,
842    body: Option<serde_json::Value>,
843}
844
845impl ApiRequest {
846    #[must_use]
847    pub fn get(path: impl Into<String>) -> Self {
848        Self::new(Method::GET, path)
849    }
850
851    #[must_use]
852    pub fn delete(path: impl Into<String>) -> Self {
853        Self::new(Method::DELETE, path)
854    }
855
856    #[must_use]
857    pub fn new(method: Method, path: impl Into<String>) -> Self {
858        Self {
859            method,
860            path: path.into(),
861            query: Vec::new(),
862            body: None,
863        }
864    }
865
866    /// # Errors
867    /// `body` failing to serialize.
868    pub fn post_json<T: Serialize>(path: impl Into<String>, body: &T) -> Result<Self, GithubError> {
869        let value = serde_json::to_value(body).map_err(|source| GithubError::Decode {
870            what: "request",
871            expected: "JSON",
872            source,
873        })?;
874        Ok(Self {
875            method: Method::POST,
876            path: path.into(),
877            query: Vec::new(),
878            body: Some(value),
879        })
880    }
881
882    #[must_use]
883    pub fn query(mut self, key: impl Into<String>, value: impl fmt::Display) -> Self {
884        self.query.push((key.into(), value.to_string()));
885        self
886    }
887
888    #[must_use]
889    pub fn method(&self) -> &Method {
890        &self.method
891    }
892
893    #[must_use]
894    pub fn path(&self) -> &str {
895        &self.path
896    }
897}
898
899impl fmt::Debug for ApiRequest {
900    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
901        f.debug_struct("ApiRequest")
902            .field("method", &self.method.as_str())
903            .field("path", &self.path)
904            .field(
905                "query_keys",
906                &self.query.iter().map(|(k, _)| k).collect::<Vec<_>>(),
907            )
908            .field(
909                "body",
910                &self.body.as_ref().map_or("none", |_| "[REDACTED JSON]"),
911            )
912            .finish()
913    }
914}
915
916/// One buffered `api.github.com` response.
917///
918/// Buffered rather than streamed because every API response this crate reads is
919/// small JSON. The one large download in the product — the runner package — is
920/// `e2`'s and uses its own streaming client.
921///
922/// `Debug` renders the status and the body's *length*, never the body: a
923/// `generate-jitconfig` response body is an encoded JIT configuration.
924#[derive(Clone)]
925pub struct ApiResponse {
926    status: StatusCode,
927    headers: HeaderMap,
928    body: Vec<u8>,
929}
930
931impl ApiResponse {
932    #[must_use]
933    pub fn status(&self) -> StatusCode {
934        self.status
935    }
936
937    #[must_use]
938    pub fn headers(&self) -> &HeaderMap {
939        &self.headers
940    }
941
942    #[must_use]
943    pub fn header(&self, name: &str) -> Option<&str> {
944        self.headers.get(name).and_then(|v| v.to_str().ok())
945    }
946
947    /// # Errors
948    /// A body that is not the expected JSON shape.
949    pub fn json<T: DeserializeOwned>(&self) -> Result<T, GithubError> {
950        serde_json::from_slice(&self.body).map_err(|source| GithubError::Decode {
951            what: "response",
952            expected: std::any::type_name::<T>(),
953            source,
954        })
955    }
956
957    /// The next page of a paginated collection, from the `Link` header.
958    ///
959    /// `04-subsystem-contracts.md`: "Pagination is mandatory; the dashboard must
960    /// not treat a first page as a complete inventory." It lives on the shared
961    /// response type so that `c3`'s inventory and this module's installation
962    /// discovery cannot disagree about how a `Link` header is read.
963    #[must_use]
964    pub fn next_page(&self) -> Option<Url> {
965        self.header("link").and_then(parse_link_next)
966    }
967}
968
969impl fmt::Debug for ApiResponse {
970    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
971        f.debug_struct("ApiResponse")
972            .field("status", &self.status.as_u16())
973            .field("body_bytes", &self.body.len())
974            .finish_non_exhaustive()
975    }
976}
977
978/// The `rel="next"` target of an RFC 8288 `Link` header, or `None`.
979///
980/// # Why this scans rather than splits
981///
982/// A comma separates one link-value from the next, but a comma is also a legal
983/// character *inside* a URL, and GitHub sends such URLs routinely — a runner
984/// query carries `labels=self-hosted,windows`. Splitting the whole header on `,`
985/// first tears that URL in half, neither half parses as `<...>`, and the
986/// relation is silently lost. The caller then treats page 1 as the whole
987/// inventory, which is the specific outcome `04-subsystem-contracts.md` forbids:
988/// "the dashboard must not treat a first page as a complete inventory".
989///
990/// So the target is located by its `<`…`>` delimiters, and only a comma that
991/// actually begins the next link-value — one followed by optional whitespace and
992/// `<` — ends the parameter section.
993fn parse_link_next(link: &str) -> Option<Url> {
994    let mut rest = link;
995    while let Some(open) = rest.find('<') {
996        let after_open = &rest[open + 1..];
997        let Some(close) = after_open.find('>') else {
998            // An unterminated `<` cannot be a link-value; nothing after it is
999            // interpretable either.
1000            return None;
1001        };
1002        let target = after_open[..close].trim();
1003        let tail = &after_open[close + 1..];
1004
1005        // The parameters run to the start of the next link-value.
1006        let cut = tail
1007            .match_indices(',')
1008            .find(|(i, _)| tail[i + 1..].trim_start().starts_with('<'))
1009            .map_or(tail.len(), |(i, _)| i);
1010        let (params, next) = tail.split_at(cut);
1011
1012        let is_next = params.split(';').any(|param| {
1013            let param = param.trim().replace(['"', '\''], "");
1014            param.eq_ignore_ascii_case("rel=next")
1015        });
1016        if is_next {
1017            return Url::parse(target).ok();
1018        }
1019        rest = next.strip_prefix(',').unwrap_or(next);
1020    }
1021    None
1022}
1023
1024#[derive(Debug, Deserialize)]
1025struct ErrorEnvelope {
1026    message: Option<String>,
1027}
1028
1029/// GitHub's own message for a failure, and never the raw body.
1030fn error_message(body: &[u8]) -> Option<String> {
1031    serde_json::from_slice::<ErrorEnvelope>(body)
1032        .ok()
1033        .and_then(|e| e.message)
1034        .filter(|m| !m.is_empty())
1035}
1036
1037fn retry_after(headers: &HeaderMap) -> Option<Duration> {
1038    headers
1039        .get("retry-after")
1040        .and_then(|v| v.to_str().ok())
1041        .and_then(|v| v.trim().parse::<u64>().ok())
1042        .map(Duration::from_secs)
1043}
1044
1045// ---------------------------------------------------------------------------
1046// Sleeping
1047// ---------------------------------------------------------------------------
1048
1049/// The one way anything in this crate waits.
1050///
1051/// A port rather than a direct `tokio::time::sleep`, for the same reason the
1052/// domain has a `Clock`: the device flow's `slow_down` handling is a *timing*
1053/// behaviour, and a timing behaviour tested by actually waiting is either
1054/// untested or slow. A test substitutes a sleeper that records the requested
1055/// durations and returns immediately, which turns "`slow_down` increases the
1056/// poll interval" into an equality assertion on a `Vec<Duration>` rather than a
1057/// stopwatch reading.
1058#[async_trait::async_trait]
1059pub trait Sleeper: Send + Sync + fmt::Debug {
1060    async fn sleep(&self, duration: Duration);
1061}
1062
1063/// The production adapter.
1064#[derive(Debug, Clone, Copy, Default)]
1065pub struct TokioSleeper;
1066
1067#[async_trait::async_trait]
1068impl Sleeper for TokioSleeper {
1069    async fn sleep(&self, duration: Duration) {
1070        tokio::time::sleep(duration).await;
1071    }
1072}
1073
1074// ---------------------------------------------------------------------------
1075// The shared authenticated client
1076// ---------------------------------------------------------------------------
1077
1078/// What a single re-validation of the held credential concluded.
1079#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1080pub enum Revalidation {
1081    /// GitHub still accepts the credential, so the `401` was about the request
1082    /// rather than about the token. One retry is warranted.
1083    Valid,
1084    /// GitHub rejects the credential. Terminal; only `auth login` clears it.
1085    Rejected,
1086    /// The probe itself could not be completed — GitHub was unreachable, or
1087    /// answered something neither `2xx` nor `401`. Nothing was learned, so the
1088    /// caller still gets its one retry.
1089    Unavailable,
1090}
1091
1092#[derive(Debug)]
1093struct LockoutState {
1094    until: Option<Timestamp>,
1095    backoff: Duration,
1096}
1097
1098/// The one client every `api.github.com` request in this crate goes through.
1099///
1100/// It exists to make four things structural rather than remembered:
1101///
1102/// 1. `X-GitHub-Api-Version`, `Accept`, `User-Agent`, and `Authorization` are
1103///    set on every request because they are set *here*.
1104/// 2. The `401` / `403` taxonomy of `03-control-flows.md` flow 4.3 is
1105///    implemented once. `c3` and `f1` both branch on the distinction, and two
1106///    implementations of it would eventually disagree.
1107/// 3. A `401` storm produces **one** credential re-validation, not one per
1108///    caller — see [`AuthenticatedClient::revalidate`].
1109/// 4. A lockout stops traffic. Once GitHub answers `403` after `401`s, this
1110///    client issues no further HTTP at all until the back-off elapses.
1111pub struct AuthenticatedClient {
1112    http: reqwest::Client,
1113    endpoints: Endpoints,
1114    /// Swappable, because a renewable credential replaces itself while this
1115    /// client is in use. A `Mutex` rather than a lock-free cell: it is read
1116    /// once per request and written once every eight hours, so contention is
1117    /// not the concern -- being obviously correct is.
1118    credential: std::sync::Mutex<UserAccessToken>,
1119    /// Where to re-read the credential when a `401` outlives renewal.
1120    ///
1121    /// `None` for a short-lived client, which is every one that is not the
1122    /// daemon's: a command that runs for a second cannot be outlived by a
1123    /// sign-in.
1124    source: Option<Arc<dyn CredentialSource>>,
1125    /// How a credential replaces itself, when it can.
1126    ///
1127    /// `None` for a client whose credential has no renewal half, which is every
1128    /// client until an App turns user-token expiration on, and for the paths
1129    /// that hold a token for one call and never outlive its eight hours.
1130    renewal: Option<Arc<dyn CredentialRenewal>>,
1131    clock: Arc<dyn Clock>,
1132
1133    /// Bumped once per completed re-validation. A caller that took a `401`
1134    /// samples this *before* queuing on the gate; if it changed while the caller
1135    /// waited, some other caller already did the work and this one must not
1136    /// repeat it. This is the whole single-flight mechanism.
1137    revalidation_generation: AtomicU64,
1138    revalidation_gate: tokio::sync::Mutex<()>,
1139    last_revalidation: std::sync::Mutex<Revalidation>,
1140    revalidations_performed: AtomicU64,
1141
1142    /// `401`s seen since the last successful caller response.
1143    ///
1144    /// **Nothing in production reads this.** It is incremented on every `401`
1145    /// and cleared by a successful caller response, and that is the whole of
1146    /// what it does. It used to be documented as the lockout's test — "a `403`
1147    /// while this is non-zero is a lockout" — and
1148    /// [`AuthenticatedClient::is_lockout_403`] stopped consulting it when that
1149    /// rule was replaced by position plus GitHub's own evidence:
1150    /// [`Attempt::Retry`] already implies this request's own `401` incremented
1151    /// it moments ago, so reading it added no signal, and it did add a race that
1152    /// failed open — a concurrent success clearing the count downgraded a real
1153    /// lockout to a permissions answer.
1154    ///
1155    /// The field is kept on purpose, and only the tests read it: they drive it
1156    /// to values that *would* change the answer if it were still consulted, and
1157    /// assert that the answer does not change. Deleting it would delete the
1158    /// ability to make that assertion, which is the only thing standing between
1159    /// the conjunct and its reintroduction. See
1160    /// `a_concurrent_success_cannot_downgrade_a_lockout_to_a_permissions_answer`.
1161    consecutive_unauthorized: AtomicU64,
1162    lockout: std::sync::Mutex<LockoutState>,
1163}
1164
1165impl fmt::Debug for AuthenticatedClient {
1166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1167        // `try_lock`, not `is_locked_out()`. `std::sync::Mutex` is not
1168        // reentrant, and `latch_lockout` holds this one; the moment anybody adds
1169        // a `tracing` call inside that function — which is a natural thing to
1170        // want there — rendering the client would deadlock the whole agent. A
1171        // `Debug` impl must never be able to block, so it reports what it can
1172        // see and says so when it cannot.
1173        let locked_out = match self.lockout.try_lock() {
1174            Ok(state) => {
1175                if state.until.is_some_and(|until| self.clock.now() < until) {
1176                    "yes"
1177                } else {
1178                    "no"
1179                }
1180            }
1181            Err(_) => "unknown (the lockout state is being updated)",
1182        };
1183        f.debug_struct("AuthenticatedClient")
1184            .field("api_base", &self.endpoints.api_base.as_str())
1185            .field(
1186                "credential",
1187                &self
1188                    .credential
1189                    .try_lock()
1190                    .map_or("[in use]", |_| "[redacted]"),
1191            )
1192            .field(
1193                "revalidations_performed",
1194                &self.revalidations_performed.load(Ordering::Relaxed),
1195            )
1196            .field("locked_out", &locked_out)
1197            .finish_non_exhaustive()
1198    }
1199}
1200
1201/// How a credential replaces itself.
1202///
1203/// # Why this is a port rather than a method
1204///
1205/// Renewal is two acts that must happen in one order: exchange the refresh
1206/// token with GitHub, then **persist the new pair before anything uses it**.
1207/// GitHub rotates on use -- the old pair dies the instant the new one is
1208/// issued -- so a response that is used but not stored leaves the host holding
1209/// a credential it will forget, and the one it forgot is already dead. There
1210/// is no retry: a spent refresh token answers `incorrect_client_credentials`,
1211/// a message about the client id and secret that is about neither.
1212///
1213/// The exchange belongs to `c2` and the store belongs to `d2`, and this crate
1214/// owns neither. So the ordering lives with whoever implements this, in one
1215/// place, rather than being a rule each caller has to remember.
1216#[async_trait::async_trait]
1217pub trait CredentialRenewal: fmt::Debug + Send + Sync {
1218    /// Exchange `refresh_token` for a fresh pair and persist it.
1219    ///
1220    /// # Errors
1221    /// Any failure; the caller treats every one the same way, by keeping the
1222    /// credential it has and letting the next `401` try again. The variant
1223    /// decides only how loudly that is reported; see [`RenewalError`].
1224    async fn renew(&self, refresh_token: &SecretString) -> Result<UserAccessToken, RenewalError>;
1225}
1226
1227/// Why [`CredentialRenewal::renew`] handed back no fresh pair.
1228#[derive(Debug, thiserror::Error)]
1229pub enum RenewalError {
1230    /// This process chose not to spend the refresh token, and GitHub was not
1231    /// asked.
1232    ///
1233    /// The pair is intact, so this is not a credential problem: a process that
1234    /// cannot store what the exchange returns leaves renewal to one that can --
1235    /// on a boot-mode host, the service. Reported quietly, because it recurs
1236    /// on every attempt for as long as the renewal window is open and nothing
1237    /// about it needs an operator.
1238    #[error("{0}")]
1239    Declined(String),
1240    /// The renewal was attempted and did not produce a stored pair.
1241    #[error("{0}")]
1242    Failed(String),
1243}
1244
1245impl From<String> for RenewalError {
1246    fn from(detail: String) -> Self {
1247        Self::Failed(detail)
1248    }
1249}
1250
1251/// Where a client can go to find out that the stored credential changed under
1252/// it.
1253///
1254/// # Why a long-running client needs this
1255///
1256/// A daemon reads the store once, at startup, and holds the result for as long
1257/// as it runs. That was invisible while the only way to change the store was to
1258/// stop the daemon — but `auth login` does not stop anything, so a host whose
1259/// credential died before its daemon started stays dead through every sign-in
1260/// meant to fix it. The operator does the right thing, watches it not work, and
1261/// has nothing to tell them why.
1262///
1263/// Watched on 2026-08-29: a Windows daemon started at `04:08Z` holding an
1264/// already-expired token, a sign-in at `10:43Z` that wrote a good pair, and 180
1265/// `unauthorized` events an hour for 28 hours without a single minute's pause
1266/// across the sign-in. See `docs/spikes/token-expiry-and-renewal.md`.
1267///
1268/// # Why it is not a file watch
1269///
1270/// This is consulted on `401` and nowhere else, so a store that never changes
1271/// costs nothing and a daemon that is working never reads the disk. It also
1272/// covers the case a watch would miss on macOS, where the credential lives in a
1273/// keychain rather than at a path.
1274pub trait CredentialSource: fmt::Debug + Send + Sync {
1275    /// The credential the store holds *now*, or `None` if it cannot be read.
1276    ///
1277    /// Infallible by design: every failure — missing, unreadable, corrupt —
1278    /// means the same thing to the caller, which is that there is nothing new
1279    /// to try and the `401` stands.
1280    fn reload(&self) -> Option<UserAccessToken>;
1281}
1282
1283impl AuthenticatedClient {
1284    /// # Errors
1285    /// The HTTP client failing to build — a TLS backend that will not
1286    /// initialise, in practice.
1287    pub fn new(
1288        endpoints: Endpoints,
1289        credential: UserAccessToken,
1290        clock: Arc<dyn Clock>,
1291    ) -> Result<Self, GithubError> {
1292        let http = reqwest::Client::builder()
1293            .timeout(DEFAULT_REQUEST_TIMEOUT)
1294            .build()
1295            .map_err(transport)?;
1296        Ok(Self::with_http_client(http, endpoints, credential, clock))
1297    }
1298
1299    #[must_use]
1300    pub fn with_http_client(
1301        http: reqwest::Client,
1302        endpoints: Endpoints,
1303        credential: UserAccessToken,
1304        clock: Arc<dyn Clock>,
1305    ) -> Self {
1306        Self {
1307            http,
1308            endpoints,
1309            credential: std::sync::Mutex::new(credential),
1310            source: None,
1311            renewal: None,
1312            clock,
1313            revalidation_generation: AtomicU64::new(0),
1314            revalidation_gate: tokio::sync::Mutex::new(()),
1315            last_revalidation: std::sync::Mutex::new(Revalidation::Valid),
1316            revalidations_performed: AtomicU64::new(0),
1317            consecutive_unauthorized: AtomicU64::new(0),
1318            lockout: std::sync::Mutex::new(LockoutState {
1319                until: None,
1320                backoff: DEFAULT_LOCKOUT_BACKOFF,
1321            }),
1322        }
1323    }
1324
1325    #[must_use]
1326    pub fn endpoints(&self) -> &Endpoints {
1327        &self.endpoints
1328    }
1329
1330    /// How many credential re-validations this client has performed.
1331    ///
1332    /// Public because it is the observable the single-flight requirement is
1333    /// stated in terms of: "concurrent callers hitting `401` together produce
1334    /// **one** attempt, not N".
1335    #[must_use]
1336    pub fn revalidations_performed(&self) -> u64 {
1337        self.revalidations_performed.load(Ordering::SeqCst)
1338    }
1339
1340    /// `true` while a lockout back-off is still running, during which this
1341    /// client issues no HTTP at all.
1342    ///
1343    /// # Panics
1344    /// If a previous holder panicked while the lockout lock was held.
1345    #[must_use]
1346    pub fn is_locked_out(&self) -> bool {
1347        self.lockout_remaining().is_some()
1348    }
1349
1350    /// How much of the lockout back-off is left, or `None` when not locked out.
1351    ///
1352    /// # Panics
1353    /// If a previous holder panicked while the lockout lock was held.
1354    #[must_use]
1355    pub fn lockout_remaining(&self) -> Option<Duration> {
1356        let state = self.lockout.lock().expect("lockout lock poisoned");
1357        let until = state.until?;
1358        let now = self.clock.now();
1359        if now >= until {
1360            return None;
1361        }
1362        (until - now).to_std().ok()
1363    }
1364
1365    /// Clear a lockout early. `f1` does not need this — the back-off expires on
1366    /// its own against the clock — but a successful interactive `auth login`
1367    /// legitimately invalidates the whole lockout premise.
1368    ///
1369    /// # Panics
1370    /// If a previous holder panicked while the lockout lock was held.
1371    pub fn clear_lockout(&self) {
1372        self.lockout.lock().expect("lockout lock poisoned").until = None;
1373        self.consecutive_unauthorized.store(0, Ordering::SeqCst);
1374    }
1375
1376    /// Send one request, applying the authentication taxonomy.
1377    ///
1378    /// On `401` this performs a single-flight credential re-validation and then
1379    /// **one** retry — never more, and never a token renewal, because there is
1380    /// nothing to renew (see [`AuthenticatedClient::revalidate`]).
1381    ///
1382    /// # Errors
1383    /// Every variant of [`GithubError`].
1384    pub async fn send(&self, request: &ApiRequest) -> Result<ApiResponse, GithubError> {
1385        if let Some(remaining) = self.lockout_remaining() {
1386            // "backs off without further attempts": no socket is opened at all.
1387            tracing::debug!(
1388                method = request.method.as_str(),
1389                path = %request.path,
1390                remaining_secs = remaining.as_secs(),
1391                "suppressed a request: GitHub authentication lockout is still backing off"
1392            );
1393            return Err(GithubError::AuthenticationLockout {
1394                retry_after: remaining,
1395            });
1396        }
1397
1398        let first = self.send_raw(request).await?;
1399        match self.classify(request, &first, Attempt::First) {
1400            Classified::Ok => Ok(first),
1401            Classified::Unauthorized => self.revalidate_and_retry_once(request).await,
1402            Classified::Error(err) => Err(err),
1403        }
1404    }
1405
1406    /// Deserialize a `GET` in one step.
1407    ///
1408    /// # Errors
1409    /// Every variant of [`GithubError`].
1410    pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, GithubError> {
1411        self.send(&ApiRequest::get(path)).await?.json()
1412    }
1413
1414    /// The access token to send, as a string, for exactly one request.
1415    ///
1416    /// Cloned out of the lock rather than borrowed through it: the value is
1417    /// about to go into a header, and holding the lock across the request would
1418    /// serialise every call in the process behind one mutex.
1419    fn bearer(&self) -> String {
1420        self.credential
1421            .lock()
1422            .unwrap_or_else(std::sync::PoisonError::into_inner)
1423            .secret()
1424            .expose_secret()
1425            .to_string()
1426    }
1427
1428    /// Attach a way for this client's credential to replace itself.
1429    #[must_use]
1430    pub fn with_renewal(mut self, renewal: Arc<dyn CredentialRenewal>) -> Self {
1431        self.renewal = Some(renewal);
1432        self
1433    }
1434
1435    /// Attach the store this client's credential came from, so a `401` it
1436    /// cannot renew its way out of can still notice a sign-in that already
1437    /// happened.
1438    #[must_use]
1439    pub fn with_credential_source(mut self, source: Arc<dyn CredentialSource>) -> Self {
1440        self.source = Some(source);
1441        self
1442    }
1443
1444    /// Replace the credential with whatever `produce` finds, once, however many
1445    /// callers asked at the same moment.
1446    ///
1447    /// Answers whether the credential in hand is now a *different* one, which
1448    /// is the caller's cue to retry. `false` means there was nothing new, and
1449    /// the `401` stands.
1450    ///
1451    /// # Why both ways in share this
1452    ///
1453    /// [`Self::renew_once`] and [`Self::reload_once`] ask one question —
1454    /// *can this `401` be retried with something else* — and differ only in
1455    /// where the something else comes from. Written separately they were two
1456    /// copies of the same `SeqCst` generation protocol, and the copies had
1457    /// already drifted: the equality check below existed in one of them and not
1458    /// the other, so a renewal that handed back an identical token reported a
1459    /// change that had not happened.
1460    ///
1461    /// # Why the comparison, and not just a swap
1462    ///
1463    /// A swap on every `401` would answer `true` forever and turn the one retry
1464    /// into an endless pair of requests against a credential that is genuinely
1465    /// dead. Only a different token is evidence that retrying might go
1466    /// differently.
1467    async fn swap_credential_once<F>(&self, produce: F, note: &'static str) -> bool
1468    where
1469        F: AsyncFnOnce(&Self) -> Option<UserAccessToken>,
1470    {
1471        let before = self.revalidation_generation.load(Ordering::SeqCst);
1472        let _gate = self.revalidation_gate.lock().await;
1473        if self.revalidation_generation.load(Ordering::SeqCst) != before {
1474            // Somebody swapped while this caller queued, so there is already
1475            // something new to retry with. Producing again would be wasted at
1476            // best and, for a renewal, would burn the pair they just minted.
1477            return true;
1478        }
1479
1480        let Some(fresh) = produce(self).await else {
1481            return false;
1482        };
1483        {
1484            let mut guard = self
1485                .credential
1486                .lock()
1487                .unwrap_or_else(std::sync::PoisonError::into_inner);
1488            if guard.secret().expose_secret() == fresh.secret().expose_secret() {
1489                return false;
1490            }
1491            *guard = fresh;
1492        }
1493        self.revalidation_generation.fetch_add(1, Ordering::SeqCst);
1494        tracing::info!("{note}");
1495        true
1496    }
1497
1498    /// Pick up a credential somebody else stored.
1499    ///
1500    /// `false` when there is no source, the store cannot be read, or what it
1501    /// holds is the token that just failed.
1502    async fn reload_once(&self) -> bool {
1503        let Some(source) = self.source.clone() else {
1504            return false;
1505        };
1506        self.swap_credential_once(
1507            async |_| source.reload(),
1508            "the stored credential changed and was picked up without a restart",
1509        )
1510        .await
1511    }
1512
1513    /// Spend the refresh half for a fresh pair.
1514    ///
1515    /// # Ordering
1516    ///
1517    /// The implementation of [`CredentialRenewal::renew`] persists before
1518    /// returning, so by the time the swap happens the new pair is already
1519    /// durable. A crash between the two loses nothing: the store holds the pair
1520    /// that works, and the next start reads it.
1521    async fn renew_once(&self) -> bool {
1522        let Some(renewal) = self.renewal.clone() else {
1523            return false;
1524        };
1525        self.swap_credential_once(
1526            async |client: &Self| {
1527                let refresh = {
1528                    let guard = client
1529                        .credential
1530                        .lock()
1531                        .unwrap_or_else(std::sync::PoisonError::into_inner);
1532                    guard.renewal().map(|r| r.refresh_token().clone())
1533                }?;
1534                match renewal.renew(&refresh).await {
1535                    Ok(fresh) => Some(fresh),
1536                    Err(RenewalError::Declined(reason)) => {
1537                        tracing::debug!(
1538                            %reason,
1539                            "left the user access token for a process that can store its renewal"
1540                        );
1541                        None
1542                    }
1543                    Err(RenewalError::Failed(error)) => {
1544                        tracing::warn!(
1545                            %error,
1546                            "the user access token could not be renewed; an interactive \
1547                             sign-in may be required"
1548                        );
1549                        None
1550                    }
1551                }
1552            },
1553            "the user access token was renewed",
1554        )
1555        .await
1556    }
1557
1558    /// Proactively replace a renewable credential when either half is close
1559    /// to expiry.
1560    ///
1561    /// Ordinary request handling also renews after a `401`, but a daemon with
1562    /// no GitHub work can make no request for months. In that state the refresh
1563    /// token itself eventually expires and an interactive sign-in is the only
1564    /// recovery. A long-running owner calls this periodically so every
1565    /// successful rotation extends the credential even while it is otherwise
1566    /// idle.
1567    ///
1568    /// The stored credential is checked first for the same reason as the `401`
1569    /// path: another process may already have rotated the pair, and spending
1570    /// its predecessor after that would be a refresh-token replay.
1571    ///
1572    /// Returns `true` when a different credential was loaded or minted. A
1573    /// credential without a refresh half is left untouched. Missing expiry
1574    /// metadata is treated as due once so that the exchange can repair an old
1575    /// stored document.
1576    pub async fn renew_if_expiring_within(&self, window: Duration) -> bool {
1577        let due = {
1578            let credential = self
1579                .credential
1580                .lock()
1581                .unwrap_or_else(std::sync::PoisonError::into_inner);
1582            let Some(renewal) = credential.renewal() else {
1583                return false;
1584            };
1585            let Some(deadline) = self.clock.now().checked_add_signed(
1586                chrono::TimeDelta::from_std(window).unwrap_or(chrono::TimeDelta::MAX),
1587            ) else {
1588                return true;
1589            };
1590            renewal
1591                .access_expires_at
1592                .is_none_or(|expires| expires <= deadline)
1593                || renewal
1594                    .refresh_expires_at
1595                    .is_some_and(|expires| expires <= deadline)
1596        };
1597
1598        due && (self.reload_once().await || self.renew_once().await)
1599    }
1600
1601    /// Serialize, `POST`, and deserialize in one step.
1602    ///
1603    /// # Errors
1604    /// Every variant of [`GithubError`].
1605    pub async fn post_json<B: Serialize, T: DeserializeOwned>(
1606        &self,
1607        path: &str,
1608        body: &B,
1609    ) -> Result<T, GithubError> {
1610        self.send(&ApiRequest::post_json(path, body)?).await?.json()
1611    }
1612
1613    /// Re-validate the credential once, no matter how many callers ask at once.
1614    ///
1615    /// # This is not a token renewal — renewal is [`Self::renew_once`]
1616    ///
1617    /// `03-control-flows.md` flow 4.3 says a `401` "triggers one refresh under
1618    /// a single-flight mutex, then one retry", and that is implemented
1619    /// literally: one attempt shared by every concurrent caller, then one retry
1620    /// each.
1621    ///
1622    /// This method used to claim the word "refresh" could not be meant, because
1623    /// renewing needs a client secret and the App issues no renewal token. Both
1624    /// halves were false; see this module's header. Renewal exists, it runs
1625    /// first in [`Self::revalidate_and_retry_once`], and what is left here is
1626    /// the path for a credential that has no refresh half to spend — one stored
1627    /// before 0.1.11, or issued while the App had expiration switched off.
1628    ///
1629    /// So the single thing that happens under the mutex is a re-validation of
1630    /// the credential already held: one `GET /user/installations` with the same
1631    /// token, asking GitHub whether it still accepts it. A
1632    /// [`Revalidation::Rejected`] answer is terminal
1633    /// [`GithubError::AuthenticationFailed`] requiring an interactive
1634    /// `auth login`; [`Revalidation::Valid`] and [`Revalidation::Unavailable`]
1635    /// both spend the one retry.
1636    ///
1637    /// # Position, and what it no longer decides on its own
1638    ///
1639    /// This method is the caller's own probe: it is a **first** attempt by
1640    /// construction, whatever happened on some other request minutes ago, so it
1641    /// passes [`Attempt::First`] down.
1642    ///
1643    /// That used to settle the matter. This heading read "why this entry point
1644    /// may not latch a lockout", and the text said the probe it drives *cannot*
1645    /// latch — an accurate description of the position rule as it then stood,
1646    /// and a false statement about the product. A lockout that outlives one
1647    /// back-off continues on a **first** attempt by construction, because this
1648    /// client's retry never happened: the request never reached the wire.
1649    /// Refusing to latch there stopped the back-off entirely and hammered a
1650    /// credential GitHub had asked to be left alone.
1651    ///
1652    /// So position alone no longer decides.
1653    /// [`AuthenticatedClient::is_lockout_403`] reads GitHub's own evidence in
1654    /// the first position instead: a first attempt latches when, and only when,
1655    /// the response carries `retry-after` and no parseable GitHub message body.
1656    /// A permissions refusal names what is not accessible, so it still does not
1657    /// latch, which is what keeps [`GithubError::Forbidden`] reachable from
1658    /// here.
1659    ///
1660    /// `revalidate_and_retry_once` uses the private
1661    /// [`AuthenticatedClient::revalidate_after_unauthorized`] instead, which is
1662    /// in the retry position, where any `403` that is not a rate limit is the
1663    /// lockout regardless of what the body says.
1664    ///
1665    /// # This call can latch a lockout, and then it says so
1666    ///
1667    /// Because a first attempt can latch, this call can leave the whole client
1668    /// backed off for up to [`MAX_LOCKOUT_BACKOFF`]. It reports that as
1669    /// [`GithubError::AuthenticationLockout`] rather than answering
1670    /// `Ok(`[`Revalidation::Unavailable`]`)` and leaving the caller to discover
1671    /// it through a separate [`AuthenticatedClient::is_locked_out`] call. An
1672    /// `auth status` that printed "could not determine" while the client it had
1673    /// just silenced sat mute for fifteen minutes would be reporting the wrong
1674    /// event, and reporting it as the milder one.
1675    ///
1676    /// # Errors
1677    /// [`GithubError::AuthenticationLockout`] if this client is already backing
1678    /// off when the call arrives, **or** if this call's own probe latches one.
1679    ///
1680    /// A credential GitHub has rejected outright is *not* an error here: that
1681    /// comes back as `Ok(`[`Revalidation::Rejected`]`)`, and what to do about it
1682    /// — prompt for `auth login` — is the caller's decision, not this method's.
1683    ///
1684    /// # Panics
1685    /// If a previous holder panicked while the re-validation result lock was
1686    /// held.
1687    pub async fn revalidate(&self) -> Result<Revalidation, GithubError> {
1688        self.revalidate_from(Attempt::First).await
1689    }
1690
1691    /// The re-validation that `send` runs between a `401` and its one retry.
1692    ///
1693    /// Identical to [`AuthenticatedClient::revalidate`] except for position:
1694    /// this one *is* the retry path, so a `403` on its probe is the lockout and
1695    /// is latched.
1696    async fn revalidate_after_unauthorized(&self) -> Result<Revalidation, GithubError> {
1697        self.revalidate_from(Attempt::Retry).await
1698    }
1699
1700    async fn revalidate_from(&self, attempt: Attempt) -> Result<Revalidation, GithubError> {
1701        // "A lockout stops traffic. This client issues no further HTTP at all
1702        // until the back-off elapses" is a property of the client, not of
1703        // `send`, and the probe is HTTP like any other. `send` has already
1704        // returned by the time it calls in here, so this guard only bites a
1705        // caller that probes on its own — and one that did would otherwise be
1706        // the single exception to the rule, which is how a rule stops holding.
1707        if let Some(retry_after) = self.lockout_remaining() {
1708            return Err(GithubError::AuthenticationLockout { retry_after });
1709        }
1710
1711        // Sample before queuing. If this changes while we wait for the gate,
1712        // someone else's re-validation covers us and we must not repeat it.
1713        let sampled = self.revalidation_generation.load(Ordering::SeqCst);
1714        let _guard = self.revalidation_gate.lock().await;
1715        let outcome = if self.revalidation_generation.load(Ordering::SeqCst) != sampled {
1716            let shared = *self
1717                .last_revalidation
1718                .lock()
1719                .expect("re-validation lock poisoned");
1720            tracing::debug!(
1721                outcome = ?shared,
1722                "reused an in-flight credential re-validation instead of starting another"
1723            );
1724            shared
1725        } else {
1726            self.revalidations_performed.fetch_add(1, Ordering::SeqCst);
1727            let fresh = self.probe_credential(attempt).await;
1728            *self
1729                .last_revalidation
1730                .lock()
1731                .expect("re-validation lock poisoned") = fresh;
1732            self.revalidation_generation.fetch_add(1, Ordering::SeqCst);
1733            tracing::info!(
1734                outcome = ?fresh,
1735                "re-validated the stored credential; it carries no refresh half to renew"
1736            );
1737            fresh
1738        };
1739
1740        // The probe is HTTP, and since the continuation rule re-widened
1741        // `Attempt::First`, HTTP from *this* entry point can latch a lockout.
1742        // `probe_credential` reports a `403` as `Unavailable` whether or not it
1743        // latched one, and `Unavailable` on its own reads as "this taught us
1744        // nothing about the credential" — so without this check `revalidate`
1745        // answers `Ok(Unavailable)` having just silenced the entire client for
1746        // up to `MAX_LOCKOUT_BACKOFF`, and the only way for `f1` to find that
1747        // out is a separate `is_locked_out()` call it has no reason to make.
1748        //
1749        // `revalidate_and_retry_once` has always re-checked after its own probe.
1750        // Doing it here instead makes the two entry points agree: before, being
1751        // told about the lockout depended on *who latched it* — the guard above
1752        // reports one latched by someone else's traffic, this reports one
1753        // latched by the caller's own probe — and that distinction is invisible
1754        // from outside and actionable by nobody.
1755        //
1756        // This changes what is reported, not when a lockout latches:
1757        // `latch_lockout` is reached on exactly the paths it was before.
1758        //
1759        // It also covers the shared branch above, which the retry path's own
1760        // check never could. A second caller arriving while the first one's
1761        // probe is in flight takes the cached `Unavailable` without probing at
1762        // all, and is just as silenced by the lockout that probe latched.
1763        if let Some(retry_after) = self.lockout_remaining() {
1764            return Err(GithubError::AuthenticationLockout { retry_after });
1765        }
1766        Ok(outcome)
1767    }
1768
1769    /// One `GET /user/installations` with the credential already held, asking
1770    /// GitHub whether it still accepts it.
1771    ///
1772    /// `attempt` is the *caller's* position, not the probe's own. A probe driven
1773    /// by `send`'s `401` handling is part of that request's retry; a probe a
1774    /// caller asked for through [`AuthenticatedClient::revalidate`] is a first
1775    /// attempt.
1776    ///
1777    /// Either may latch a lockout, on different evidence. This used to say "only
1778    /// the former may", which was the position rule before the continuation rule
1779    /// re-widened `Attempt::First`: a retry `403` that is not a rate limit is
1780    /// the lockout outright, and a first-attempt `403` is the lockout when
1781    /// GitHub's own evidence says so — `retry-after` present, no parseable
1782    /// message. See [`AuthenticatedClient::is_lockout_403`], which both this and
1783    /// `classify` go through, so the rule is stated once instead of twice.
1784    async fn probe_credential(&self, attempt: Attempt) -> Revalidation {
1785        let probe = ApiRequest::get(REVALIDATION_PATH).query("per_page", 1);
1786        match self.send_raw(&probe).await {
1787            // Deliberately does **not** reset `consecutive_unauthorized`. The
1788            // probe is this client's own diagnostic, not the caller's traffic,
1789            // and a successful probe is exactly the state a lockout arrives in:
1790            // GitHub still accepts the credential, and answers the *next* real
1791            // request with `403`. Only a successful caller request clears the
1792            // count, in `classify`.
1793            //
1794            // The reason used to be given as "resetting here would erase the
1795            // evidence the `403` is classified against", and that stopped being
1796            // true when `is_lockout_403` stopped consulting the count. There is
1797            // no such evidence to erase now — nothing in production reads the
1798            // field. The line stays because the field's one remaining job is to
1799            // let the tests prove it is *not* consulted, and a probe that
1800            // quietly rewrote it would make those tests assert against a counter
1801            // value no caller path actually produces.
1802            Ok(response) if response.status.is_success() => Revalidation::Valid,
1803            Ok(response) if response.status == StatusCode::UNAUTHORIZED => {
1804                self.consecutive_unauthorized.fetch_add(1, Ordering::SeqCst);
1805                Revalidation::Rejected
1806            }
1807            Ok(response) if response.status == StatusCode::FORBIDDEN => {
1808                // A `403` on a probe that *is* this request's retry is the
1809                // lockout outright. A `403` on a probe a caller asked for
1810                // directly is the lockout only when the response itself says so
1811                // — `retry-after` present, no parseable message — because that
1812                // is what a lockout continuing past one back-off looks like, and
1813                // it necessarily arrives in the first position.
1814                //
1815                // The comment that used to sit here claimed "the probe only ever
1816                // runs after a `401`, so it is always in the retry position",
1817                // and publishing `revalidate` is what made that untrue. Its
1818                // replacement then said a direct probe "is not" the lockout,
1819                // which the continuation rule in turn made untrue. Both were
1820                // position asserted as a conclusion; the position now arrives as
1821                // an argument and the conclusion is drawn in one place, by
1822                // `is_lockout_403`.
1823                if self.is_lockout_403(&response, attempt) {
1824                    self.latch_lockout(&response.headers);
1825                }
1826                Revalidation::Unavailable
1827            }
1828            Ok(_) | Err(_) => Revalidation::Unavailable,
1829        }
1830    }
1831
1832    async fn revalidate_and_retry_once(
1833        &self,
1834        request: &ApiRequest,
1835    ) -> Result<ApiResponse, GithubError> {
1836        // ------------------------------------------------------------------
1837        // RENEWAL COMES FIRST, AND IT DID NOT USED TO EXIST.
1838        // ------------------------------------------------------------------
1839        // This method's documentation once said a `401` could only ever be
1840        // re-validated, never renewed, because renewing needs a confidential
1841        // client credential and a published binary cannot carry one. The first
1842        // half of that is wrong: GitHub requires one *"unless the user access
1843        // token was generated using the device flow"*, and this product's
1844        // always are. Verified against live GitHub before this was written.
1845        //
1846        // So a credential that carries a refresh token replaces itself here,
1847        // silently, and the caller's request is retried with the new one. That
1848        // is what lets an eight-hour token serve a daemon that runs for months
1849        // -- and what lets two machines hold their own credentials at once,
1850        // because each renews its own pair instead of re-authorising and
1851        // revoking the other's.
1852        //
1853        // A credential with no refresh token falls through to exactly the
1854        // behaviour that was here before.
1855        //
1856        // ------------------------------------------------------------------
1857        // THEN THE STORE, FOR THE 401 RENEWAL CANNOT ANSWER.
1858        // ------------------------------------------------------------------
1859        // Renewal covers a token that expired under a daemon holding a
1860        // *renewable* pair. It cannot cover a daemon that started holding a
1861        // dead bare token, because there is no refresh half to spend -- and
1862        // that daemon will not recover on its own no matter how many times an
1863        // operator runs `auth login`, because it never looks at the store
1864        // again. Consulting it here is what makes the obvious remedy work.
1865        //
1866        // First, not second: if multiple processes (e.g., daemon and TUI) share the
1867        // same token, they will both get 401 at the same time. The first one to renew
1868        // will write the new token to disk. If the second process tries to renew with
1869        // its in-memory refresh token *before* reloading, GitHub will detect a refresh
1870        // token replay and instantly revoke the entire token chain. So we must always
1871        // check disk for a newer token first.
1872        //
1873        // To further prevent a race condition if two processes hit 401 at the exact
1874        // same millisecond, we introduce a pseudo-random jitter based on the OS
1875        // process ID. This ensures one process wakes up first, finishes the renewal,
1876        // and writes to disk, so the second process sees the new file during its
1877        // `reload_once()`. All threads in the *same* process compute the exact same
1878        // jitter, preserving their ability to coalesce behind `revalidation_gate`.
1879        let jitter = (std::process::id() % 1500) as u64 + 50;
1880        tokio::time::sleep(std::time::Duration::from_millis(jitter)).await;
1881
1882        if self.reload_once().await || self.renew_once().await {
1883            let second = self.send_raw(request).await?;
1884            return match self.classify(request, &second, Attempt::Retry) {
1885                Classified::Ok => Ok(second),
1886                // The renewed credential was rejected too. Nothing here can
1887                // help: this is a sign-in, not a token, that has gone.
1888                Classified::Unauthorized => Err(GithubError::AuthenticationFailed),
1889                Classified::Error(err) => Err(err),
1890            };
1891        }
1892        match self.revalidate_after_unauthorized().await? {
1893            Revalidation::Rejected => {
1894                tracing::warn!(
1895                    method = request.method.as_str(),
1896                    path = %request.path,
1897                    "GitHub rejected the stored credential; re-authentication is required"
1898                );
1899                Err(GithubError::AuthenticationFailed)
1900            }
1901            Revalidation::Valid | Revalidation::Unavailable => {
1902                // `revalidate_from` now converts a lockout that its own probe
1903                // latched, so this no longer catches that case. It stays for the
1904                // one it still catches: a *concurrent* request latching between
1905                // that check and this one. Sending the retry into a live lockout
1906                // is the thing the back-off exists to prevent, and this is the
1907                // last point at which it can be declined.
1908                if let Some(remaining) = self.lockout_remaining() {
1909                    return Err(GithubError::AuthenticationLockout {
1910                        retry_after: remaining,
1911                    });
1912                }
1913                let second = self.send_raw(request).await?;
1914                match self.classify(request, &second, Attempt::Retry) {
1915                    Classified::Ok => Ok(second),
1916                    // The one retry is spent. A second `401` is terminal.
1917                    Classified::Unauthorized => Err(GithubError::AuthenticationFailed),
1918                    Classified::Error(err) => Err(err),
1919                }
1920            }
1921        }
1922    }
1923
1924    fn classify(
1925        &self,
1926        request: &ApiRequest,
1927        response: &ApiResponse,
1928        attempt: Attempt,
1929    ) -> Classified {
1930        let status = response.status;
1931        if status.is_success() {
1932            self.consecutive_unauthorized.store(0, Ordering::SeqCst);
1933            return Classified::Ok;
1934        }
1935        if status == StatusCode::UNAUTHORIZED {
1936            self.consecutive_unauthorized.fetch_add(1, Ordering::SeqCst);
1937            return Classified::Unauthorized;
1938        }
1939        if status == StatusCode::FORBIDDEN && self.is_lockout_403(response, attempt) {
1940            let backoff = self.latch_lockout(&response.headers);
1941            tracing::warn!(
1942                method = request.method.as_str(),
1943                path = %request.path,
1944                backoff_secs = backoff.as_secs(),
1945                "GitHub answered 403 after 401s: temporary authentication lockout, backing off"
1946            );
1947            return Classified::Error(GithubError::AuthenticationLockout {
1948                retry_after: backoff,
1949            });
1950        }
1951        let headers = Box::new(response.headers.clone());
1952        let message = error_message(&response.body);
1953        if status == StatusCode::FORBIDDEN {
1954            return Classified::Error(GithubError::Forbidden {
1955                method: request.method.as_str().to_string(),
1956                path: request.path.clone(),
1957                message,
1958                headers,
1959            });
1960        }
1961        Classified::Error(GithubError::Status {
1962            status: status.as_u16(),
1963            method: request.method.as_str().to_string(),
1964            path: request.path.clone(),
1965            message,
1966            headers,
1967        })
1968    }
1969
1970    /// Whether a `403` is GitHub's temporary *authentication* lockout, as
1971    /// opposed to a permissions answer or a rate limit.
1972    ///
1973    /// # It must not be a rate limit
1974    ///
1975    /// `classify` used to reach the `403` branch before anything looked at the
1976    /// rate-limit headers, so a primary rate limit arriving during a `401` storm
1977    /// was reported as `AuthenticationLockout` — telling the operator "the
1978    /// credential itself is not the problem" about a response that never
1979    /// mentioned the credential. Recognising GitHub's own rate-limit evidence is
1980    /// not rate-limit *policy*; it is declining to make an assertion the
1981    /// evidence contradicts. What to do about the rate limit stays `c3`'s, which
1982    /// is why this only changes which variant carries the headers onward.
1983    ///
1984    /// # Then one of two positions, and the second one is a fix for the first
1985    ///
1986    /// **The retry.** `consecutive_unauthorized` counts `401`s since the last
1987    /// successful caller response and — correctly — does not decay: a request
1988    /// that ends in `404`, `422` or `500` leaves it set. In the agent's
1989    /// long-lived reconciliation loop that meant a single `401` from minutes ago
1990    /// converted the *next* genuine permissions `403` into a fake lockout: sixty
1991    /// seconds of silence plus an operator message insisting the credential is
1992    /// fine, when in truth `generate-jitconfig` was missing
1993    /// `Administration: write`. The lockout's signature is narrower than "a
1994    /// `403` while the count is set" — it is a `403` on the one retry this
1995    /// client itself issued after this request's own `401`.
1996    ///
1997    /// The count is deliberately *not* consulted. [`Attempt::Retry`] already
1998    /// means this request's own `401` incremented it moments ago, so reading it
1999    /// adds no signal — and does add a race that fails open: any concurrent
2000    /// request succeeding between the `401` and the retry `store(0)`s the
2001    /// counter, and a real lockout is then reported as a plain permissions
2002    /// refusal. A conjunct that can only ever weaken a safety check is worse
2003    /// than no conjunct.
2004    ///
2005    /// **The continuation.** Narrowing to the retry position opened a hole at
2006    /// the far end of the same back-off. When the back-off elapses and GitHub is
2007    /// still locking the credential out, the next request is a *first* attempt
2008    /// by construction — this client's retry never happened, because the request
2009    /// never reached the wire. The position rule then declined to call it a
2010    /// lockout, `classify` fell through to [`GithubError::Forbidden`] — whose
2011    /// documented reading is "the App installation does not grant it" — and the
2012    /// client **stopped backing off entirely**, hammering a credential GitHub
2013    /// had asked it to leave alone. That is the exact inverse of the
2014    /// Definition of Done's "backs off without retrying", and it failed for
2015    /// every lockout outliving one back-off.
2016    ///
2017    /// No counter is needed for that case either, because the response says so
2018    /// itself. GitHub's lockout carries `retry-after` and no parseable message;
2019    /// a permissions refusal carries a message naming what is not accessible and
2020    /// no `retry-after`. Requiring **both** halves of that signature is what
2021    /// keeps this from degenerating into "every `403` is a lockout": a
2022    /// permissions answer has a message, so it never matches, and a secondary
2023    /// rate limit has both a message and `retry-after`, so `is_rate_limited`
2024    /// takes it first.
2025    ///
2026    /// "No parseable message" is deliberately wider than "an empty body", which
2027    /// is how this used to be stated. See [`is_lockout_continuation`] for what
2028    /// else falls into it — a proxy's HTML error page most notably — and for why
2029    /// the resulting false positives are accepted rather than tightened away.
2030    ///
2031    /// This also settles a standing worry about [`MAX_LOCKOUT_BACKOFF`]. With
2032    /// the continuation recognised, the ceiling no longer decides whether the
2033    /// product ever gives up — it only decides how often it re-asks. A lockout
2034    /// longer than the ceiling now re-latches instead of being reported as a
2035    /// permissions failure, so the value is a polling interval rather than a
2036    /// deadline.
2037    fn is_lockout_403(&self, response: &ApiResponse, attempt: Attempt) -> bool {
2038        if is_rate_limited(response) {
2039            return false;
2040        }
2041        match attempt {
2042            Attempt::Retry => true,
2043            Attempt::First => is_lockout_continuation(response),
2044        }
2045    }
2046
2047    fn latch_lockout(&self, headers: &HeaderMap) -> Duration {
2048        // Clamp before latching. An unclamped `Retry-After` is a remote party
2049        // deciding how long this product stays down.
2050        let requested = retry_after(headers).unwrap_or(DEFAULT_LOCKOUT_BACKOFF);
2051        let clamped = requested.min(MAX_LOCKOUT_BACKOFF);
2052
2053        // A span too large for `chrono` must fall back to the default, never to
2054        // `None`: the old code's `.ok()` turned an absurd `Retry-After` into "no
2055        // lockout at all", which fails *open* — the exact inverse of what a
2056        // back-off is for, and reachable by a header alone. The clamp above
2057        // already makes this branch unreachable; it stays because the invariant
2058        // it protects ("latching always latches") is worth more than the line.
2059        let delta = chrono::TimeDelta::from_std(clamped).unwrap_or_else(|_| {
2060            chrono::TimeDelta::from_std(DEFAULT_LOCKOUT_BACKOFF)
2061                .expect("sixty seconds is a representable span")
2062        });
2063        // No third clamp. `clamped` is already `<= MAX_LOCKOUT_BACKOFF`, and
2064        // `TimeDelta` round-trips it exactly, so re-clamping here was dead twice
2065        // over — it could only ever re-apply a bound already applied, and the
2066        // fallback it guarded is `DEFAULT_LOCKOUT_BACKOFF`, which is smaller
2067        // than the ceiling by construction.
2068        let backoff = delta.to_std().unwrap_or(DEFAULT_LOCKOUT_BACKOFF);
2069
2070        let mut state = self.lockout.lock().expect("lockout lock poisoned");
2071        state.backoff = backoff;
2072        state.until = Some(self.clock.now() + delta);
2073        backoff
2074    }
2075
2076    /// One HTTP round trip with the standard headers applied and no
2077    /// interpretation of the result.
2078    async fn send_raw(&self, request: &ApiRequest) -> Result<ApiResponse, GithubError> {
2079        let url = self.resolve(&request.path)?;
2080        let mut builder = self
2081            .http
2082            .request(request.method.clone(), url)
2083            .header(reqwest::header::ACCEPT, GITHUB_ACCEPT)
2084            .header(reqwest::header::USER_AGENT, USER_AGENT)
2085            .header("X-GitHub-Api-Version", GITHUB_API_VERSION)
2086            // The only place the token is ever written onto the wire. It is
2087            // never logged, and `reqwest` does not render headers in its errors.
2088            .header(
2089                reqwest::header::AUTHORIZATION,
2090                format!("Bearer {}", self.bearer()),
2091            );
2092        if !request.query.is_empty() {
2093            builder = builder.query(&request.query);
2094        }
2095        if let Some(body) = &request.body {
2096            builder = builder.json(body);
2097        }
2098
2099        let response = builder.send().await.map_err(transport)?;
2100        let status = response.status();
2101        let headers = response.headers().clone();
2102        let body = response.bytes().await.map_err(transport)?.to_vec();
2103
2104        tracing::debug!(
2105            method = request.method.as_str(),
2106            path = %request.path,
2107            status = status.as_u16(),
2108            body_bytes = body.len(),
2109            "github api request"
2110        );
2111
2112        Ok(ApiResponse {
2113            status,
2114            headers,
2115            body,
2116        })
2117    }
2118
2119    fn resolve(&self, path: &str) -> Result<Url, GithubError> {
2120        if path.starts_with("http://") || path.starts_with("https://") {
2121            return Url::parse(path).map_err(|_| GithubError::Malformed {
2122                what: "an absolute request URL",
2123                value: path.to_string(),
2124            });
2125        }
2126        self.endpoints
2127            .api_base
2128            .join(path.trim_start_matches('/'))
2129            .map_err(|_| GithubError::Malformed {
2130                what: "a request path",
2131                value: path.to_string(),
2132            })
2133    }
2134}
2135
2136enum Classified {
2137    Ok,
2138    Unauthorized,
2139    Error(GithubError),
2140}
2141
2142/// Which of a request's at-most-two attempts produced a response.
2143///
2144/// The authentication lockout is defined by *position*, not just by status: it
2145/// is what GitHub answers the retry that follows a `401`. Passing this in makes
2146/// that explicit at both call sites instead of inferring it from a counter that
2147/// outlives the request.
2148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2149enum Attempt {
2150    First,
2151    Retry,
2152}
2153
2154/// Whether GitHub attributed a failing response to its own rate limit.
2155///
2156/// Reading the evidence, and nothing else — see [`GithubError`]'s note on why
2157/// the headers travel with the error. `c3` decides what to do about it.
2158fn is_rate_limited(response: &ApiResponse) -> bool {
2159    if response.status == StatusCode::TOO_MANY_REQUESTS {
2160        return true;
2161    }
2162    // The primary rate limit's documented signature.
2163    if response
2164        .header("x-ratelimit-remaining")
2165        .is_some_and(|v| v.trim() == "0")
2166    {
2167        return true;
2168    }
2169    // A secondary rate limit sends `retry-after` — but so does the
2170    // authentication lockout, so that header alone cannot tell them apart.
2171    // GitHub's own message ("You have exceeded a secondary rate limit") can.
2172    error_message(&response.body).is_some_and(|m| m.to_ascii_lowercase().contains("rate limit"))
2173}
2174
2175/// Whether a `403` on a *first* attempt is GitHub continuing an authentication
2176/// lockout that outlived this client's back-off.
2177///
2178/// The two halves are both required, and both are GitHub's own evidence rather
2179/// than this client's memory:
2180///
2181/// * **`retry-after` is present.** GitHub sends it when it wants to be left
2182///   alone. A permissions refusal never does — there is nothing to wait for.
2183///
2184///   The header's *presence* is what is tested, not whether it parses, and that
2185///   is a fix rather than laziness. [`retry_after`] reads **integer seconds
2186///   only**, while RFC 9110 §10.2.3 also permits an HTTP-date. Gating detection
2187///   on `retry_after(..).is_some()` meant a date-form header was not recognised
2188///   as a continuation at all, and the bug this function exists to fix came
2189///   straight back for that shape — silently, since the response still looks
2190///   like an ordinary [`GithubError::Forbidden`] on the way out.
2191///
2192///   How long to wait stays a separate question, still answered by the integer
2193///   parse: [`AuthenticatedClient::latch_lockout`] already falls back to
2194///   [`DEFAULT_LOCKOUT_BACKOFF`] for a header it cannot read, so a date-form
2195///   header now latches sixty seconds instead of latching nothing. GitHub sends
2196///   integer seconds in practice; the point is not to depend on that.
2197/// * **The body carries no parseable GitHub message.** A permissions refusal
2198///   always names what is not accessible ("Resource not accessible by
2199///   integration"); the lockout's does not. This is the half that stops the rule
2200///   from swallowing [`GithubError::Forbidden`] entirely.
2201///
2202///   "No parseable GitHub message" is wider than "the body is empty", which is
2203///   how this used to be written, and the difference is worth stating because it
2204///   is what the code actually tests. [`error_message`] returns `None` for *any*
2205///   body that is not JSON carrying a non-empty `message`: an HTML error page
2206///   from a proxy or a CDN, a JSON body carrying only `documentation_url`, plain
2207///   text, a truncated response. Proxies routinely send `Retry-After` too, so a
2208///   `403` that never came from GitHub at all can read as an authentication
2209///   lockout here.
2210///
2211///   That is accepted rather than missed. The cost is bounded — the client
2212///   waits, clamped by [`MAX_LOCKOUT_BACKOFF`], then re-asks — and the direction
2213///   is the safe one: treating a strange `403` as "wait" costs latency, while
2214///   treating a real lockout as a permissions answer costs the back-off
2215///   entirely and tells the operator to fix a grant that is not missing.
2216///   Tightening it would mean asserting the body *is* GitHub's, which is
2217///   precisely the assertion an intercepting proxy makes false.
2218///
2219/// Callers reach this through [`AuthenticatedClient::is_lockout_403`], which
2220/// rules out a rate limit first — a secondary rate limit carries `retry-after`
2221/// *and* a message, so it fails this test on the second half anyway, but the
2222/// ordering makes the precedence explicit rather than incidental.
2223fn is_lockout_continuation(response: &ApiResponse) -> bool {
2224    response.headers.contains_key("retry-after") && error_message(&response.body).is_none()
2225}
2226
2227// ---------------------------------------------------------------------------
2228// Installation discovery
2229// ---------------------------------------------------------------------------
2230
2231/// Whether an installation can reach every repository on its account, or only
2232/// the ones the user picked.
2233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2234#[serde(rename_all = "snake_case")]
2235pub enum RepositorySelection {
2236    /// Every repository on the account, including ones created later.
2237    All,
2238    /// Only the repositories the user chose at install time.
2239    Selected,
2240}
2241
2242impl RepositorySelection {
2243    /// `07-security.md`: "`auth status` shows which repositories the token can
2244    /// reach, so an over-broad installation is visible rather than assumed."
2245    /// This is the flag that makes it visible.
2246    #[must_use]
2247    pub fn is_over_broad(self) -> bool {
2248        matches!(self, Self::All)
2249    }
2250}
2251
2252/// Whose account an installation sits on.
2253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2254#[serde(rename_all = "snake_case")]
2255pub enum InstallationAccount {
2256    User(String),
2257    Organization(Org),
2258    /// An enterprise account.
2259    ///
2260    /// It is its own variant rather than a [`InstallationAccount::User`]
2261    /// because it is not one, and `auth status` says out loud whose account
2262    /// each installation sits on. Everything GitHub reports without
2263    /// `type: "Organization"` used to fall into `User`, so an enterprise was
2264    /// labelled a user — a wrong statement about the operator's own account, on
2265    /// the one screen that exists to tell them what their credential reaches.
2266    ///
2267    /// It contributes nothing to [`ReachableTargets::organizations`], and that
2268    /// is correct rather than a second bug: an enterprise is not an
2269    /// organization, and `GET /orgs/{org}/actions/runners` does not accept one.
2270    /// The distinction is only visible now because the label is.
2271    Enterprise(String),
2272}
2273
2274impl InstallationAccount {
2275    #[must_use]
2276    pub fn login(&self) -> &str {
2277        match self {
2278            Self::User(login) | Self::Enterprise(login) => login,
2279            Self::Organization(org) => org.as_str(),
2280        }
2281    }
2282
2283    /// The organization, when the account is one. An organization account is a
2284    /// reachable *target* in its own right (D18): a policy may scale for the
2285    /// whole organization.
2286    #[must_use]
2287    pub fn organization(&self) -> Option<&Org> {
2288        match self {
2289            Self::Organization(org) => Some(org),
2290            Self::User(_) | Self::Enterprise(_) => None,
2291        }
2292    }
2293
2294    /// What to call this account in `auth status`. `f1` renders it; nothing in
2295    /// this crate branches on it.
2296    #[must_use]
2297    pub fn kind(&self) -> &'static str {
2298        match self {
2299            Self::User(_) => "user",
2300            Self::Organization(_) => "organization",
2301            Self::Enterprise(_) => "enterprise",
2302        }
2303    }
2304}
2305
2306impl fmt::Display for InstallationAccount {
2307    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2308        f.write_str(self.login())
2309    }
2310}
2311
2312/// One installation of the published App, and what it can actually reach.
2313///
2314/// Serializable, as is everything [`InstallationDiscovery`] holds, so that a
2315/// service can answer a discovery for a command that cannot read the
2316/// credential itself (an SSH session on a Mac, whose login keychain it cannot
2317/// unlock). Nothing in it is a secret.
2318#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2319pub struct Installation {
2320    pub id: u64,
2321    pub account: InstallationAccount,
2322    pub repository_selection: RepositorySelection,
2323    pub repositories: Vec<OwnerRepo>,
2324    /// The permissions GitHub reports for this installation, as
2325    /// `name -> level`. Surfaced verbatim so `auth status` can show a grant the
2326    /// user did not expect rather than assert the published set was applied.
2327    pub permissions: Vec<(String, String)>,
2328}
2329
2330impl Installation {
2331    #[must_use]
2332    pub fn is_over_broad(&self) -> bool {
2333        self.repository_selection.is_over_broad()
2334    }
2335}
2336
2337/// Everything the stored credential can reach.
2338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2339pub struct ReachableTargets {
2340    installations: Vec<Installation>,
2341    skipped: usize,
2342}
2343
2344impl ReachableTargets {
2345    #[must_use]
2346    pub fn installations(&self) -> &[Installation] {
2347        &self.installations
2348    }
2349
2350    /// How many installations GitHub reported that this client could not
2351    /// describe, and therefore left out of everything above.
2352    ///
2353    /// Non-zero means this report is **incomplete**, not merely small: whatever
2354    /// those installations reach is absent from
2355    /// [`ReachableTargets::repositories`] and
2356    /// [`ReachableTargets::organizations`]. `auth status` should say so, because
2357    /// the alternative is an operator reading a short list as a complete one.
2358    #[must_use]
2359    pub fn skipped(&self) -> usize {
2360        self.skipped
2361    }
2362
2363    /// Every repository the credential can reach, sorted and de-duplicated.
2364    #[must_use]
2365    pub fn repositories(&self) -> Vec<OwnerRepo> {
2366        let mut all: Vec<OwnerRepo> = self
2367            .installations
2368            .iter()
2369            .flat_map(|i| i.repositories.iter().cloned())
2370            .collect();
2371        all.sort();
2372        all.dedup();
2373        all
2374    }
2375
2376    /// Every organization the App is installed on, sorted and de-duplicated.
2377    #[must_use]
2378    pub fn organizations(&self) -> Vec<Org> {
2379        let mut all: Vec<Org> = self
2380            .installations
2381            .iter()
2382            .filter_map(|i| i.account.organization().cloned())
2383            .collect();
2384        all.sort();
2385        all.dedup();
2386        all
2387    }
2388
2389    /// The installations that hold `repository_selection: all`.
2390    #[must_use]
2391    pub fn over_broad(&self) -> Vec<&Installation> {
2392        self.installations
2393            .iter()
2394            .filter(|i| i.is_over_broad())
2395            .collect()
2396    }
2397
2398    #[must_use]
2399    pub fn is_empty(&self) -> bool {
2400        self.repositories().is_empty() && self.organizations().is_empty()
2401    }
2402}
2403
2404/// What `auth status` and `auth login` show after a successful sign-in.
2405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2406#[serde(rename_all = "snake_case")]
2407pub enum InstallationDiscovery {
2408    /// The credential is valid, GitHub reported nothing this client could not
2409    /// describe, and still nothing is reachable: the App is installed nowhere,
2410    /// or on nothing. `03-control-flows.md` flow 1.1 requires the installation
2411    /// URL here, and the URL is the remediation.
2412    NotInstalled {
2413        #[serde(with = "url_text")]
2414        install_url: Url,
2415    },
2416    /// Nothing is reachable, but at least one installation was **skipped**, so
2417    /// this client cannot tell "not installed" from "installed on something it
2418    /// could not describe".
2419    ///
2420    /// # Why this variant exists at all
2421    ///
2422    /// Skipping an unnameable installation is the right trade — one odd
2423    /// installation must not take down `auth status` for every other one — but
2424    /// it was made silently, and the silence flipped a verdict. An account this
2425    /// client cannot name, on the *only* installation the credential has, used
2426    /// to collapse to [`InstallationDiscovery::NotInstalled`], and `auth status`
2427    /// then handed an already-installed operator the "install the App" URL. That
2428    /// is a wrong remediation on the only authentication path there is,
2429    /// contradicted by nothing louder than a `warn!` in a log the operator is
2430    /// not reading.
2431    ///
2432    /// So the skip stays and the verdict does not flip. There is deliberately no
2433    /// `install_url` here: the whole point is that this client does not know
2434    /// whether installing is the remedy, and offering the URL anyway would put
2435    /// the wrong answer back one field over. `f1` says "1 installation could not
2436    /// be described" and stops there, which is true.
2437    Indeterminate { skipped: usize },
2438    /// The credential reaches at least one repository or organization. It may
2439    /// still be an incomplete picture — see [`ReachableTargets::skipped`].
2440    Installed(ReachableTargets),
2441}
2442
2443impl InstallationDiscovery {
2444    #[must_use]
2445    pub fn targets(&self) -> Option<&ReachableTargets> {
2446        match self {
2447            Self::Installed(t) => Some(t),
2448            Self::NotInstalled { .. } | Self::Indeterminate { .. } => None,
2449        }
2450    }
2451
2452    /// The installation URL, and *only* when installing is actually the
2453    /// remediation. See [`InstallationDiscovery::Indeterminate`].
2454    #[must_use]
2455    pub fn install_url(&self) -> Option<&Url> {
2456        match self {
2457            Self::NotInstalled { install_url } => Some(install_url),
2458            Self::Installed(_) | Self::Indeterminate { .. } => None,
2459        }
2460    }
2461
2462    /// How many installations GitHub reported that this client could not
2463    /// describe, whichever verdict was reached. One call for `f1`, so that
2464    /// "this report is incomplete" does not depend on which variant it landed
2465    /// in.
2466    #[must_use]
2467    pub fn skipped(&self) -> usize {
2468        match self {
2469            Self::NotInstalled { .. } => 0,
2470            Self::Indeterminate { skipped } => *skipped,
2471            Self::Installed(targets) => targets.skipped(),
2472        }
2473    }
2474}
2475
2476/// A [`Url`] as its text: this workspace builds `url` without its `serde`
2477/// feature.
2478mod url_text {
2479    use serde::{Deserialize as _, Deserializer, Serializer};
2480    use url::Url;
2481
2482    pub(super) fn serialize<S: Serializer>(url: &Url, serializer: S) -> Result<S::Ok, S::Error> {
2483        serializer.serialize_str(url.as_str())
2484    }
2485
2486    pub(super) fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Url, D::Error> {
2487        let text = String::deserialize(deserializer)?;
2488        Url::parse(&text).map_err(serde::de::Error::custom)
2489    }
2490}
2491
2492#[derive(Debug, Deserialize)]
2493struct InstallationsPage {
2494    /// GitHub reports the size of the whole collection on every page. Decoding
2495    /// it costs nothing and turns silent under-collection into a visible
2496    /// warning — see [`under_collected`].
2497    #[serde(default)]
2498    total_count: Option<u64>,
2499    #[serde(default)]
2500    installations: Vec<RawInstallation>,
2501}
2502
2503#[derive(Debug, Deserialize)]
2504struct RawInstallation {
2505    id: u64,
2506    /// **Nullable.** GitHub's published `installation` schema types `account` as
2507    /// nullable, so a required field here would fail the *whole* decode — and
2508    /// with it all of `discover_installations`, which is all of `auth status` —
2509    /// over one installation whose account this client did not need to name.
2510    #[serde(default)]
2511    account: Option<RawAccount>,
2512    #[serde(default)]
2513    repository_selection: Option<String>,
2514    #[serde(default)]
2515    permissions: std::collections::BTreeMap<String, String>,
2516}
2517
2518/// An installation's account, which is *not* always a simple user.
2519///
2520/// GitHub's schema makes `account` either a simple-user or an enterprise, and an
2521/// enterprise carries `slug` and `name` where a user carries `login`. Requiring
2522/// `login` therefore made an enterprise installation a hard decode failure of
2523/// the entire response. All three are optional here and
2524/// [`RawAccount::display_login`] takes the first usable one.
2525#[derive(Debug, Deserialize)]
2526struct RawAccount {
2527    #[serde(default)]
2528    login: Option<String>,
2529    /// An enterprise account's stable identifier.
2530    #[serde(default)]
2531    slug: Option<String>,
2532    /// An enterprise account's display name, the last resort.
2533    #[serde(default)]
2534    name: Option<String>,
2535    #[serde(rename = "type", default)]
2536    account_type: Option<String>,
2537}
2538
2539impl RawAccount {
2540    fn display_login(&self) -> Option<&str> {
2541        [
2542            self.login.as_deref(),
2543            self.slug.as_deref(),
2544            self.name.as_deref(),
2545        ]
2546        .into_iter()
2547        .flatten()
2548        .find(|value| !value.is_empty())
2549    }
2550
2551    /// An account with no `login` that still names itself is an enterprise:
2552    /// `slug`/`name` is the enterprise shape, and every simple-user and
2553    /// organization account carries `login`.
2554    fn is_enterprise_shaped(&self) -> bool {
2555        self.login.as_deref().is_none_or(str::is_empty)
2556            && (self.slug.as_deref().is_some_and(|s| !s.is_empty())
2557                || self.name.as_deref().is_some_and(|s| !s.is_empty()))
2558    }
2559}
2560
2561#[derive(Debug, Deserialize)]
2562struct RepositoriesPage {
2563    #[serde(default)]
2564    total_count: Option<u64>,
2565    #[serde(default)]
2566    repositories: Vec<RawRepository>,
2567}
2568
2569#[derive(Debug, Deserialize)]
2570struct RawRepository {
2571    full_name: String,
2572}
2573
2574/// How many items a paginated collection said it had, when that is more than
2575/// arrived.
2576///
2577/// This is the cheapest possible check and it is worth more than it looks. The
2578/// `Link`-header parser used to lose the relation whenever a page URL contained
2579/// a comma, which stopped pagination at page 1 — and *nothing* noticed, because
2580/// a short answer and a complete answer are the same shape. Cross-checking the
2581/// count GitHub itself reported turns that class of bug from a wrong answer into
2582/// a logged warning. A collection larger than `total_count` is not reported:
2583/// GitHub can legitimately grow a collection between pages.
2584fn under_collected(collected: usize, total_count: Option<u64>) -> Option<u64> {
2585    let total = total_count?;
2586    (total > collected as u64).then_some(total)
2587}
2588
2589impl AuthenticatedClient {
2590    /// Which repositories and organizations the stored credential can actually
2591    /// reach.
2592    ///
2593    /// Two calls, both paginated: `GET /user/installations`, then
2594    /// `GET /user/installations/{id}/repositories` per installation. The shapes
2595    /// are the ones the D18 spike observed live
2596    /// (`docs/spikes/d18-org-jit-verification.md`, "The permission that
2597    /// authorized it").
2598    ///
2599    /// An installation is reported even when it is broader than the user
2600    /// expected — [`Installation::is_over_broad`] — because `07-security.md`
2601    /// requires that an over-broad installation be *visible* rather than
2602    /// assumed. Nothing here narrows or hides one.
2603    ///
2604    /// # Errors
2605    /// Every variant of [`GithubError`]. A `401` here goes through the same
2606    /// single-flight re-validation as any other request.
2607    pub async fn discover_installations(
2608        &self,
2609        app: &AppRegistration,
2610    ) -> Result<InstallationDiscovery, GithubError> {
2611        let mut installations = Vec::new();
2612        let mut skipped = 0_usize;
2613        for raw in self.all_installations().await? {
2614            // A null or nameless account is skipped rather than fatal. GitHub
2615            // types this field as nullable, and one unnameable installation must
2616            // not take down `auth status` for every other one — but it is also
2617            // not something to swallow quietly, because the repositories behind
2618            // it are then absent from the reported reach. The count is what
2619            // carries that out of here; a `warn!` alone let the skip change the
2620            // verdict with nothing to say so.
2621            let Some(login) = raw.account.as_ref().and_then(RawAccount::display_login) else {
2622                skipped += 1;
2623                tracing::warn!(
2624                    installation_id = raw.id,
2625                    "skipping an installation GitHub reported with no nameable account; \
2626                     anything it reaches is missing from this report"
2627                );
2628                continue;
2629            };
2630            let account_type = raw.account.as_ref().and_then(|a| a.account_type.as_deref());
2631            let account = match account_type {
2632                Some("Organization") => {
2633                    InstallationAccount::Organization(Org::new(login).map_err(|_| {
2634                        GithubError::Malformed {
2635                            what: "an installation account login",
2636                            value: login.to_string(),
2637                        }
2638                    })?)
2639                }
2640                Some("Enterprise") => InstallationAccount::Enterprise(login.to_string()),
2641                // An enterprise is also reported with no `type` at all, carrying
2642                // `slug`/`name` where a user carries `login` — which is the
2643                // shape D18 observed and the shape `display_login` exists for.
2644                // Recognising it by that shape is what stops it being labelled a
2645                // user by default.
2646                _ if raw
2647                    .account
2648                    .as_ref()
2649                    .is_some_and(RawAccount::is_enterprise_shaped) =>
2650                {
2651                    InstallationAccount::Enterprise(login.to_string())
2652                }
2653                _ => InstallationAccount::User(login.to_string()),
2654            };
2655            let repository_selection = match raw.repository_selection.as_deref() {
2656                Some("all") => RepositorySelection::All,
2657                _ => RepositorySelection::Selected,
2658            };
2659            installations.push(Installation {
2660                id: raw.id,
2661                account,
2662                repository_selection,
2663                repositories: self.installation_repositories(raw.id).await?,
2664                permissions: raw.permissions.into_iter().collect(),
2665            });
2666        }
2667
2668        let targets = ReachableTargets {
2669            installations,
2670            skipped,
2671        };
2672        if targets.is_empty() {
2673            // "Nothing reachable" and "nothing this client could describe" are
2674            // different answers, and only the first one is fixed by installing
2675            // the App. Reporting them as the same answer is how an
2676            // already-installed operator was handed an install URL.
2677            if skipped > 0 {
2678                tracing::warn!(
2679                    skipped,
2680                    "every installation GitHub reported was skipped; whether the App is \
2681                     installed cannot be determined from this credential"
2682                );
2683                return Ok(InstallationDiscovery::Indeterminate { skipped });
2684            }
2685            let install_url = app.install_url(&self.endpoints);
2686            tracing::info!(
2687                install_url = %install_url,
2688                "the published App is not installed on anything this credential can reach"
2689            );
2690            return Ok(InstallationDiscovery::NotInstalled { install_url });
2691        }
2692        tracing::info!(
2693            repositories = targets.repositories().len(),
2694            organizations = targets.organizations().len(),
2695            over_broad = targets.over_broad().len(),
2696            skipped,
2697            "discovered the targets this credential can reach"
2698        );
2699        Ok(InstallationDiscovery::Installed(targets))
2700    }
2701
2702    async fn all_installations(&self) -> Result<Vec<RawInstallation>, GithubError> {
2703        let mut out = Vec::new();
2704        let mut total_count = None;
2705        let mut next = Some(ApiRequest::get("/user/installations").query("per_page", 100));
2706        let mut pages = 0_usize;
2707        while let Some(request) = next.take() {
2708            let response = self.send(&request).await?;
2709            let page: InstallationsPage = response.json()?;
2710            total_count = page.total_count.or(total_count);
2711            out.extend(page.installations);
2712
2713            pages += 1;
2714            if pages >= MAX_PAGES {
2715                tracing::warn!(
2716                    pages,
2717                    collected = out.len(),
2718                    "stopped following installation pages at the ceiling; a `Link: rel=next` \
2719                     that never ends would otherwise loop forever"
2720                );
2721                break;
2722            }
2723            next = response
2724                .next_page()
2725                .map(|url| ApiRequest::get(url.as_str()));
2726        }
2727        if let Some(expected) = under_collected(out.len(), total_count) {
2728            tracing::warn!(
2729                expected,
2730                collected = out.len(),
2731                "GitHub reported more installations than pagination collected; the reported \
2732                 reach is incomplete"
2733            );
2734        }
2735        Ok(out)
2736    }
2737
2738    async fn installation_repositories(&self, id: u64) -> Result<Vec<OwnerRepo>, GithubError> {
2739        let mut out = Vec::new();
2740        let mut total_count = None;
2741        let mut next = Some(
2742            ApiRequest::get(format!("/user/installations/{id}/repositories"))
2743                .query("per_page", 100),
2744        );
2745        let mut pages = 0_usize;
2746        while let Some(request) = next.take() {
2747            let response = self.send(&request).await?;
2748            let page: RepositoriesPage = response.json()?;
2749            total_count = page.total_count.or(total_count);
2750            for repo in page.repositories {
2751                out.push(OwnerRepo::parse(&repo.full_name).map_err(|_| {
2752                    GithubError::Malformed {
2753                        what: "a repository full_name",
2754                        value: repo.full_name.clone(),
2755                    }
2756                })?);
2757            }
2758
2759            pages += 1;
2760            if pages >= MAX_PAGES {
2761                tracing::warn!(
2762                    installation_id = id,
2763                    pages,
2764                    collected = out.len(),
2765                    "stopped following repository pages at the ceiling; a `Link: rel=next` \
2766                     that never ends would otherwise loop forever"
2767                );
2768                break;
2769            }
2770            next = response
2771                .next_page()
2772                .map(|url| ApiRequest::get(url.as_str()));
2773        }
2774        if let Some(expected) = under_collected(out.len(), total_count) {
2775            tracing::warn!(
2776                installation_id = id,
2777                expected,
2778                collected = out.len(),
2779                "GitHub reported more repositories than pagination collected; this \
2780                 installation's reach is under-reported"
2781            );
2782        }
2783        Ok(out)
2784    }
2785}
2786
2787/// Test support shared by this file and [`device_flow`].
2788///
2789/// It lives inline rather than in `src/testing.rs` on purpose. `a1` laid out
2790/// this crate's five source files — `lib.rs`, `device_flow.rs`, `rest.rs`,
2791/// `demand.rs`, `jit.rs` — and owns every manifest; `c3` and `c4` are working in
2792/// the same directory in parallel, and a new file there is a merge conflict
2793/// waiting to happen for no benefit. An inline `#[cfg(test)]` module is
2794/// reachable as `crate::testing` from every module in the crate and adds nothing
2795/// to a release build.
2796///
2797/// It does not live in `runner-manager-testkit` either, and that one is
2798/// mechanical: `testkit` depends on `runner-manager-github`, so a unit test
2799/// inside this crate that used a `testkit` helper would link a *second* instance
2800/// of this library and the two instances' types would not unify — the same
2801/// hazard `testkit`'s own crate documentation records for `domain`.
2802#[cfg(test)]
2803pub(crate) mod testing {
2804    use super::*;
2805    use serde_json::{Value, json};
2806    use std::sync::{Mutex, atomic::AtomicUsize};
2807    use wiremock::{Request, Respond, ResponseTemplate};
2808
2809    /// Shaped like a real `ghu_` token, and unmistakably not one.
2810    pub const FIXTURE_TOKEN: &str = "ghu_fixtureTOKENnotARealCredential00";
2811    /// Shaped like a real device code, and unmistakably not one.
2812    pub const FIXTURE_DEVICE_CODE: &str = "fixture-device-code-0e37a9c1b4d84f2a";
2813    /// The example user code from RFC 8628.
2814    pub const FIXTURE_USER_CODE: &str = "WDJB-MJHT";
2815
2816    /// A clock the test moves.
2817    ///
2818    /// Deliberately not `runner_manager_testkit::clock::FakeClock`; see this
2819    /// module's documentation for why a `testkit` import is not available here.
2820    #[derive(Debug)]
2821    pub struct TestClock {
2822        now: Mutex<Timestamp>,
2823    }
2824
2825    impl TestClock {
2826        /// # Panics
2827        /// If a previous holder panicked while the lock was held.
2828        pub fn advance_secs(&self, secs: i64) {
2829            let mut now = self.now.lock().expect("TestClock lock poisoned");
2830            *now += chrono::TimeDelta::seconds(secs);
2831        }
2832    }
2833
2834    impl Default for TestClock {
2835        fn default() -> Self {
2836            // 2026-08-21T00:00:00Z, the date this taskflow's decisions were
2837            // locked — the same epoch `testkit`'s clock starts at.
2838            Self {
2839                now: Mutex::new(
2840                    chrono::DateTime::from_timestamp(1_787_270_400, 0).expect("a valid instant"),
2841                ),
2842            }
2843        }
2844    }
2845
2846    impl Clock for TestClock {
2847        fn now(&self) -> Timestamp {
2848            *self.now.lock().expect("TestClock lock poisoned")
2849        }
2850    }
2851
2852    /// A sleeper that records what it was asked to wait and returns at once.
2853    ///
2854    /// This is what turns "`slow_down` demonstrably increases the poll interval"
2855    /// into an equality assertion on a `Vec<Duration>`.
2856    #[derive(Debug, Default)]
2857    pub struct RecordingSleeper {
2858        recorded: Mutex<Vec<Duration>>,
2859    }
2860
2861    impl RecordingSleeper {
2862        /// # Panics
2863        /// If a previous holder panicked while the lock was held.
2864        pub fn recorded(&self) -> Vec<Duration> {
2865            self.recorded.lock().expect("sleeper lock poisoned").clone()
2866        }
2867    }
2868
2869    #[async_trait::async_trait]
2870    impl Sleeper for RecordingSleeper {
2871        async fn sleep(&self, duration: Duration) {
2872            self.recorded
2873                .lock()
2874                .expect("sleeper lock poisoned")
2875                .push(duration);
2876        }
2877    }
2878
2879    /// Answers from a fixed script, one entry per call, repeating the last.
2880    pub struct Script {
2881        responses: Vec<ResponseTemplate>,
2882        calls: AtomicUsize,
2883    }
2884
2885    impl Script {
2886        #[must_use]
2887        pub fn new(responses: Vec<ResponseTemplate>) -> Self {
2888            assert!(
2889                !responses.is_empty(),
2890                "a script needs at least one response"
2891            );
2892            Self {
2893                responses,
2894                calls: AtomicUsize::new(0),
2895            }
2896        }
2897    }
2898
2899    impl Respond for Script {
2900        fn respond(&self, _: &Request) -> ResponseTemplate {
2901            let i = self.calls.fetch_add(1, Ordering::SeqCst);
2902            self.responses[i.min(self.responses.len() - 1)].clone()
2903        }
2904    }
2905
2906    /// `POST https://github.com/login/device/code` → `200`, in the shape both
2907    /// spikes observed (`docs/spikes/d17-spike.ps1`).
2908    #[must_use]
2909    pub fn device_code_body(server_uri: &str, interval: u64, expires_in: u64) -> Value {
2910        json!({
2911            "device_code": FIXTURE_DEVICE_CODE,
2912            "user_code": FIXTURE_USER_CODE,
2913            "verification_uri": format!("{server_uri}/login/device"),
2914            "expires_in": expires_in,
2915            "interval": interval
2916        })
2917    }
2918
2919    /// `POST .../login/oauth/access_token` → `200` with an `error` field, which
2920    /// is how GitHub answers every state in the matrix.
2921    #[must_use]
2922    pub fn error_body(code: &str, interval: Option<u64>) -> Value {
2923        let mut body = json!({
2924            "error": code,
2925            "error_description": "see the OAuth 2.0 Device Authorization Grant",
2926            "error_uri": "https://docs.github.com/developers/apps/authorizing-oauth-apps"
2927        });
2928        if let Some(interval) = interval {
2929            body["interval"] = json!(interval);
2930        }
2931        body
2932    }
2933
2934    /// `POST .../login/oauth/access_token` → `200` with an approved token.
2935    #[must_use]
2936    pub fn token_body() -> Value {
2937        json!({ "access_token": FIXTURE_TOKEN, "token_type": "bearer", "scope": "" })
2938    }
2939
2940    /// `GET /user/installations` → `200`. The permission set is the one D18 read
2941    /// back from the live installation.
2942    #[must_use]
2943    pub fn installations_body(entries: &[(u64, &str, &str, &str)]) -> Value {
2944        let installations: Vec<Value> = entries
2945            .iter()
2946            .map(|(id, login, account_type, selection)| {
2947                json!({
2948                    "id": id,
2949                    "account": { "login": login, "type": account_type },
2950                    "repository_selection": selection,
2951                    "permissions": {
2952                        "actions": "read",
2953                        "administration": "write",
2954                        "metadata": "read",
2955                        "organization_self_hosted_runners": "write"
2956                    }
2957                })
2958            })
2959            .collect();
2960        json!({ "total_count": installations.len(), "installations": installations })
2961    }
2962
2963    /// `GET /user/installations/{id}/repositories` → `200`.
2964    #[must_use]
2965    pub fn repositories_body(full_names: &[&str]) -> Value {
2966        let repositories: Vec<Value> = full_names
2967            .iter()
2968            .map(|full_name| json!({ "full_name": full_name }))
2969            .collect();
2970        json!({ "total_count": repositories.len(), "repositories": repositories })
2971    }
2972
2973    // The `tracing` capture subscriber that used to live here now lives in
2974    // `tests/no_secret_reaches_the_logs.rs`, and the move is the point rather
2975    // than tidying. `tracing` caches a callsite's `Interest` process-wide while
2976    // `with_default` installs a subscriber only on the calling *thread*, so a
2977    // scan running alongside the crate's other unit tests captured nothing but
2978    // its own handful of events and passed with a real device-code leak in the
2979    // flow. A scan that is the only test in its process has no concurrent
2980    // thread to be poisoned by, and no `#[cfg(test)]` module here can offer
2981    // that guarantee.
2982    //
2983    // The blinding is a *concurrency* effect and not a permanent
2984    // first-registration one — see that file's header for the measurement that
2985    // separates the two. The distinction matters here because only the
2986    // concurrency reading implies what this comment concludes: that one test
2987    // per process is the fix.
2988}
2989
2990#[cfg(test)]
2991mod tests {
2992
2993    /// Upgrading must not log anybody out, and the App's expiration setting must
2994    /// be safe to turn on -- or back off -- with hosts mid-way through either.
2995    #[test]
2996    fn both_stored_shapes_load_and_a_pair_survives_a_round_trip() {
2997        // What every host stored before renewal existed: a bare token.
2998        let legacy = UserAccessToken::from_stored_document(&SecretString::from("ghu_legacy123"));
2999        assert_eq!(legacy.secret().expose_secret(), "ghu_legacy123");
3000        assert!(
3001            legacy.renewal().is_none(),
3002            "a bare token has no renewal half, and inventing one would make the client try to              refresh a credential the App never issued a refresh token for"
3003        );
3004
3005        // A pair, written and read back.
3006        let pair = UserAccessToken::new(SecretString::from("ghu_new")).with_renewal(
3007            Some(SecretString::from("ghr_new")),
3008            Some(28_800),
3009            Some(15_897_600),
3010        );
3011        let stored = pair.to_stored_document();
3012        let read = UserAccessToken::from_stored_document(&stored);
3013        assert_eq!(read.secret().expose_secret(), "ghu_new");
3014        let renewal = read.renewal().expect("the pair survives the round trip");
3015        assert_eq!(renewal.refresh_token().expose_secret(), "ghr_new");
3016        assert!(renewal.access_expires_at.is_some());
3017        assert!(renewal.refresh_expires_at.is_some());
3018
3019        // A credential with no renewal still writes the document shape, and
3020        // still reads back as having none.
3021        let bare_round_trip = UserAccessToken::from_stored_document(&legacy.to_stored_document());
3022        assert_eq!(bare_round_trip.secret().expose_secret(), "ghu_legacy123");
3023        assert!(bare_round_trip.renewal().is_none());
3024    }
3025
3026    /// The refresh token is the more dangerous half -- it mints access tokens
3027    /// for six months -- so it must not reach a log through `Debug`.
3028    #[test]
3029    fn a_refresh_token_never_appears_in_debug_output() {
3030        let pair = UserAccessToken::new(SecretString::from("ghu_x")).with_renewal(
3031            Some(SecretString::from("ghr_SUPERSECRET")),
3032            Some(1),
3033            Some(2),
3034        );
3035        let rendered = format!("{:?}", pair.renewal().expect("a renewal"));
3036        assert!(!rendered.contains("ghr_SUPERSECRET"), "{rendered}");
3037        assert!(rendered.contains("redacted"), "{rendered}");
3038    }
3039    use super::*;
3040    use crate::testing::{FIXTURE_TOKEN, Script, TestClock, installations_body, repositories_body};
3041    use serde_json::json;
3042    use wiremock::{
3043        Mock, MockServer, ResponseTemplate,
3044        matchers::{header, method, path},
3045    };
3046
3047    fn client(server: &MockServer, clock: Arc<TestClock>) -> AuthenticatedClient {
3048        AuthenticatedClient::new(
3049            Endpoints::for_test_server(&server.uri()).unwrap(),
3050            UserAccessToken::new(SecretString::from(FIXTURE_TOKEN)),
3051            clock,
3052        )
3053        .unwrap()
3054    }
3055
3056    fn app() -> AppRegistration {
3057        AppRegistration::new("Iv23liTESTCLIENTID", "runner-manager").unwrap()
3058    }
3059
3060    // -- picking up a credential somebody else stored -------------------------
3061
3062    /// A [`CredentialSource`] over a fixed answer, which is what a store looks
3063    /// like from here.
3064    #[derive(Debug)]
3065    struct StoreHolding(Option<&'static str>);
3066
3067    impl CredentialSource for StoreHolding {
3068        fn reload(&self) -> Option<UserAccessToken> {
3069            self.0
3070                .map(|token| UserAccessToken::new(SecretString::from(token)))
3071        }
3072    }
3073
3074    /// The 28-hour failure, as a test: a daemon holding a dead bare token, an
3075    /// operator who signs in, and nothing that tells the daemon.
3076    ///
3077    /// Bare on purpose. A pair would renew and never reach the store at all,
3078    /// which is why renewal alone did not cover this.
3079    #[tokio::test]
3080    async fn a_daemon_picks_up_a_sign_in_that_happened_after_it_started() {
3081        let server = MockServer::start().await;
3082        Mock::given(method("GET"))
3083            .and(path("/repos/acme/app"))
3084            .and(header("authorization", "Bearer ghu_dead"))
3085            .respond_with(ResponseTemplate::new(401))
3086            .expect(1)
3087            .mount(&server)
3088            .await;
3089        Mock::given(method("GET"))
3090            .and(path("/repos/acme/app"))
3091            .and(header("authorization", "Bearer ghu_freshly_signed_in"))
3092            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": 1})))
3093            .expect(1)
3094            .mount(&server)
3095            .await;
3096
3097        let client = AuthenticatedClient::new(
3098            Endpoints::for_test_server(&server.uri()).unwrap(),
3099            UserAccessToken::new(SecretString::from("ghu_dead")),
3100            Arc::new(TestClock::default()),
3101        )
3102        .unwrap()
3103        .with_credential_source(Arc::new(StoreHolding(Some("ghu_freshly_signed_in"))));
3104
3105        client
3106            .send(&ApiRequest::get("/repos/acme/app"))
3107            .await
3108            .expect(
3109                "the 401 is retried with what the store holds now, without anybody \n                 restarting the daemon",
3110            );
3111    }
3112
3113    /// The other half, and the reason for the comparison in `reload_once`: a
3114    /// store that still holds the token that just failed is not news.
3115    ///
3116    /// Without the check, every `401` would answer "something changed, retry"
3117    /// and a genuinely revoked credential would spend two requests per poll
3118    /// forever instead of being reported.
3119    #[tokio::test]
3120    async fn a_store_holding_the_same_dead_token_is_not_worth_a_retry() {
3121        let server = MockServer::start().await;
3122        Mock::given(method("GET"))
3123            .and(path("/repos/acme/app"))
3124            .respond_with(ResponseTemplate::new(401))
3125            .expect(1)
3126            .mount(&server)
3127            .await;
3128        // The re-validation probe that runs once reload declines.
3129        Mock::given(method("GET"))
3130            .and(path("/user/installations"))
3131            .respond_with(ResponseTemplate::new(401))
3132            .expect(1)
3133            .mount(&server)
3134            .await;
3135
3136        let client = AuthenticatedClient::new(
3137            Endpoints::for_test_server(&server.uri()).unwrap(),
3138            UserAccessToken::new(SecretString::from("ghu_revoked")),
3139            Arc::new(TestClock::default()),
3140        )
3141        .unwrap()
3142        .with_credential_source(Arc::new(StoreHolding(Some("ghu_revoked"))));
3143
3144        let failure = client
3145            .send(&ApiRequest::get("/repos/acme/app"))
3146            .await
3147            .expect_err("a revoked credential is still revoked when the store agrees");
3148        assert!(
3149            matches!(failure, GithubError::AuthenticationFailed),
3150            "{failure:?}"
3151        );
3152    }
3153
3154    /// An unreadable store leaves the `401` exactly where it was, rather than
3155    /// turning a rejection into a different kind of error.
3156    #[tokio::test]
3157    async fn an_unreadable_store_changes_nothing_about_the_rejection() {
3158        let server = MockServer::start().await;
3159        Mock::given(method("GET"))
3160            .and(path("/repos/acme/app"))
3161            .respond_with(ResponseTemplate::new(401))
3162            .expect(1)
3163            .mount(&server)
3164            .await;
3165        Mock::given(method("GET"))
3166            .and(path("/user/installations"))
3167            .respond_with(ResponseTemplate::new(401))
3168            .expect(1)
3169            .mount(&server)
3170            .await;
3171
3172        let client = AuthenticatedClient::new(
3173            Endpoints::for_test_server(&server.uri()).unwrap(),
3174            UserAccessToken::new(SecretString::from("ghu_revoked")),
3175            Arc::new(TestClock::default()),
3176        )
3177        .unwrap()
3178        .with_credential_source(Arc::new(StoreHolding(None)));
3179
3180        let failure = client
3181            .send(&ApiRequest::get("/repos/acme/app"))
3182            .await
3183            .expect_err("nothing to pick up means the rejection stands");
3184        assert!(
3185            matches!(failure, GithubError::AuthenticationFailed),
3186            "{failure:?}"
3187        );
3188    }
3189
3190    // -- headers ------------------------------------------------------------
3191
3192    #[tokio::test]
3193    async fn every_request_states_its_api_version_and_accept_header() {
3194        let server = MockServer::start().await;
3195        Mock::given(method("GET"))
3196            .and(path("/user/installations"))
3197            .and(header("x-github-api-version", GITHUB_API_VERSION))
3198            .and(header("accept", GITHUB_ACCEPT))
3199            .and(header("authorization", format!("Bearer {FIXTURE_TOKEN}")))
3200            .and(header("user-agent", USER_AGENT))
3201            .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3202            .expect(1)
3203            .mount(&server)
3204            .await;
3205
3206        let client = client(&server, Arc::new(TestClock::default()));
3207        client
3208            .send(&ApiRequest::get("/user/installations"))
3209            .await
3210            .expect("the mock only matches when all four headers are present");
3211    }
3212
3213    // -- the 401 path -------------------------------------------------------
3214
3215    #[tokio::test]
3216    async fn a_401_revalidates_once_and_retries_once_then_succeeds() {
3217        let server = MockServer::start().await;
3218        Mock::given(method("GET"))
3219            .and(path("/orgs/acme/actions/runners"))
3220            .respond_with(Script::new(vec![
3221                ResponseTemplate::new(401).set_body_json(json!({"message": "Bad credentials"})),
3222                ResponseTemplate::new(200).set_body_json(json!({"total_count": 0})),
3223            ]))
3224            .expect(2)
3225            .mount(&server)
3226            .await;
3227        Mock::given(method("GET"))
3228            .and(path("/user/installations"))
3229            .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3230            .expect(1)
3231            .mount(&server)
3232            .await;
3233
3234        let client = client(&server, Arc::new(TestClock::default()));
3235        let response = client
3236            .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3237            .await
3238            .expect("the retry succeeds");
3239
3240        assert_eq!(response.status(), StatusCode::OK);
3241        assert_eq!(
3242            client.revalidations_performed(),
3243            1,
3244            "one 401 must produce exactly one re-validation"
3245        );
3246    }
3247
3248    #[tokio::test]
3249    async fn a_second_401_after_the_retry_is_terminal_authentication_failure() {
3250        let server = MockServer::start().await;
3251        Mock::given(method("GET"))
3252            .and(path("/orgs/acme/actions/runners"))
3253            .respond_with(ResponseTemplate::new(401))
3254            .expect(2)
3255            .mount(&server)
3256            .await;
3257        Mock::given(method("GET"))
3258            .and(path("/user/installations"))
3259            .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3260            .mount(&server)
3261            .await;
3262
3263        let client = client(&server, Arc::new(TestClock::default()));
3264        let err = client
3265            .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3266            .await
3267            .expect_err("two 401s is terminal");
3268
3269        assert!(matches!(err, GithubError::AuthenticationFailed), "{err:?}");
3270        assert!(err.is_authentication());
3271        assert!(!err.is_lockout());
3272    }
3273
3274    #[tokio::test]
3275    async fn a_rejected_revalidation_fails_without_spending_the_retry() {
3276        let server = MockServer::start().await;
3277        Mock::given(method("GET"))
3278            .and(path("/orgs/acme/actions/runners"))
3279            .respond_with(ResponseTemplate::new(401))
3280            // Exactly one: a credential GitHub has confirmed dead must not be
3281            // used for a retry.
3282            .expect(1)
3283            .mount(&server)
3284            .await;
3285        Mock::given(method("GET"))
3286            .and(path("/user/installations"))
3287            .respond_with(ResponseTemplate::new(401))
3288            .expect(1)
3289            .mount(&server)
3290            .await;
3291
3292        let client = client(&server, Arc::new(TestClock::default()));
3293        let err = client
3294            .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3295            .await
3296            .expect_err("the credential is dead");
3297        assert!(matches!(err, GithubError::AuthenticationFailed), "{err:?}");
3298    }
3299
3300    /// The Definition of Done's concurrency claim, tested with real concurrent
3301    /// callers on a multi-threaded runtime rather than by reasoning about the
3302    /// mutex.
3303    ///
3304    /// Two things make the assertion deterministic rather than lucky. A barrier
3305    /// releases all eight callers into `send` together, so all eight take their
3306    /// `401` before any of them reaches the gate; and the re-validation endpoint
3307    /// is delayed, so the first caller still holds the gate while the other
3308    /// seven sample the generation counter. Without the delay a caller could
3309    /// legitimately arrive after the first re-validation completed, which is a
3310    /// *new* `401` storm and correctly earns its own attempt.
3311    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
3312    async fn eight_concurrent_401s_produce_one_revalidation_not_eight() {
3313        const CALLERS: usize = 8;
3314
3315        let server = MockServer::start().await;
3316        Mock::given(method("GET"))
3317            .and(path("/orgs/acme/actions/runners"))
3318            .respond_with(ResponseTemplate::new(401))
3319            .mount(&server)
3320            .await;
3321        Mock::given(method("GET"))
3322            .and(path("/user/installations"))
3323            .respond_with(
3324                ResponseTemplate::new(200)
3325                    .set_body_json(installations_body(&[]))
3326                    .set_delay(Duration::from_millis(250)),
3327            )
3328            .expect(1)
3329            .mount(&server)
3330            .await;
3331
3332        let client = Arc::new(client(&server, Arc::new(TestClock::default())));
3333        let barrier = Arc::new(tokio::sync::Barrier::new(CALLERS));
3334        let mut tasks = Vec::new();
3335        for _ in 0..CALLERS {
3336            let client = Arc::clone(&client);
3337            let barrier = Arc::clone(&barrier);
3338            tasks.push(tokio::spawn(async move {
3339                barrier.wait().await;
3340                client
3341                    .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3342                    .await
3343                    .expect_err("every caller sees a dead endpoint")
3344            }));
3345        }
3346
3347        let mut outcomes = Vec::new();
3348        for task in tasks {
3349            outcomes.push(task.await.expect("no caller panicked"));
3350        }
3351
3352        assert_eq!(outcomes.len(), CALLERS);
3353        for err in &outcomes {
3354            assert!(matches!(err, GithubError::AuthenticationFailed), "{err:?}");
3355        }
3356        assert_eq!(
3357            client.revalidations_performed(),
3358            1,
3359            "{CALLERS} concurrent 401s must produce ONE attempt, not {CALLERS}"
3360        );
3361
3362        // The same claim, measured from the server rather than from our own
3363        // counter: the mock's `.expect(1)` is verified when the server drops.
3364        let seen = server.received_requests().await.expect("recording is on");
3365        let probes = seen
3366            .iter()
3367            .filter(|r| r.url.path() == "/user/installations")
3368            .count();
3369        assert_eq!(probes, 1, "GitHub itself saw exactly one re-validation");
3370        let attempts = seen
3371            .iter()
3372            .filter(|r| r.url.path() == "/orgs/acme/actions/runners")
3373            .count();
3374        assert_eq!(
3375            attempts,
3376            CALLERS * 2,
3377            "each caller still gets its own single retry"
3378        );
3379    }
3380
3381    // -- the 403 path -------------------------------------------------------
3382
3383    #[tokio::test]
3384    async fn a_403_after_401s_is_a_lockout_and_not_an_authentication_failure() {
3385        let server = MockServer::start().await;
3386        Mock::given(method("GET"))
3387            .and(path("/orgs/acme/actions/runners"))
3388            .respond_with(Script::new(vec![
3389                ResponseTemplate::new(401),
3390                ResponseTemplate::new(403).insert_header("retry-after", "42"),
3391            ]))
3392            .mount(&server)
3393            .await;
3394        Mock::given(method("GET"))
3395            .and(path("/user/installations"))
3396            .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3397            .mount(&server)
3398            .await;
3399
3400        let client = client(&server, Arc::new(TestClock::default()));
3401        let err = client
3402            .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3403            .await
3404            .expect_err("403 after a 401");
3405
3406        match err {
3407            GithubError::AuthenticationLockout { retry_after } => {
3408                assert_eq!(retry_after, Duration::from_secs(42), "honours retry-after");
3409            }
3410            other => panic!("expected a lockout, got {other:?}"),
3411        }
3412        assert!(client.is_locked_out());
3413    }
3414
3415    #[tokio::test]
3416    async fn a_403_with_no_preceding_401_is_a_permissions_answer_not_a_lockout() {
3417        let server = MockServer::start().await;
3418        Mock::given(method("GET"))
3419            .and(path("/orgs/acme/actions/runners"))
3420            .respond_with(
3421                ResponseTemplate::new(403)
3422                    .set_body_json(json!({"message": "Resource not accessible by integration"})),
3423            )
3424            .mount(&server)
3425            .await;
3426
3427        let client = client(&server, Arc::new(TestClock::default()));
3428        let err = client
3429            .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3430            .await
3431            .expect_err("403");
3432
3433        assert!(matches!(err, GithubError::Forbidden { .. }), "{err:?}");
3434        assert!(!err.is_lockout());
3435        assert!(!err.is_authentication());
3436        assert!(!client.is_locked_out(), "a permissions 403 must not latch");
3437    }
3438
3439    #[tokio::test]
3440    async fn a_locked_out_client_issues_no_further_http_until_the_backoff_elapses() {
3441        let server = MockServer::start().await;
3442        Mock::given(method("GET"))
3443            .and(path("/orgs/acme/actions/runners"))
3444            .respond_with(Script::new(vec![
3445                ResponseTemplate::new(401),
3446                ResponseTemplate::new(403).insert_header("retry-after", "60"),
3447                ResponseTemplate::new(200).set_body_json(json!({"total_count": 0})),
3448            ]))
3449            .mount(&server)
3450            .await;
3451        Mock::given(method("GET"))
3452            .and(path("/user/installations"))
3453            .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3454            .mount(&server)
3455            .await;
3456
3457        let clock = Arc::new(TestClock::default());
3458        let client = client(&server, Arc::clone(&clock));
3459        let request = ApiRequest::get("/orgs/acme/actions/runners");
3460
3461        let err = client.send(&request).await.expect_err("locks out");
3462        assert!(err.is_lockout(), "{err:?}");
3463
3464        let after_lockout = server.received_requests().await.unwrap().len();
3465
3466        for _ in 0..3 {
3467            let err = client.send(&request).await.expect_err("still locked out");
3468            assert!(err.is_lockout(), "{err:?}");
3469        }
3470        assert_eq!(
3471            server.received_requests().await.unwrap().len(),
3472            after_lockout,
3473            "a backed-off client must open no sockets at all"
3474        );
3475
3476        clock.advance_secs(61);
3477        assert!(!client.is_locked_out(), "the back-off expires on the clock");
3478        let response = client.send(&request).await.expect("traffic resumes");
3479        assert_eq!(response.status(), StatusCode::OK);
3480        assert_eq!(
3481            server.received_requests().await.unwrap().len(),
3482            after_lockout + 1
3483        );
3484    }
3485
3486    /// `consecutive_unauthorized` is reset only by a successful *caller*
3487    /// response, so a request ending in `404`, `422` or `5xx` leaves it set —
3488    /// and in the agent's long-lived reconciliation loop it stays set for as
3489    /// long as nothing succeeds. Before the fix, the next genuine permissions
3490    /// `403` was therefore reported as `AuthenticationLockout`: sixty seconds of
3491    /// client silence, plus an operator message asserting "the credential itself
3492    /// is not the problem" about a credential that was missing
3493    /// `Administration: write` — the failure `04-subsystem-contracts.md` names
3494    /// as the *expected* one for `generate-jitconfig`.
3495    ///
3496    /// The lockout's real signature is narrower: a `403` on the one retry this
3497    /// client issues after this request's own `401`, or a `403` whose own
3498    /// headers and body say GitHub is continuing a lockout. Neither is "the
3499    /// count is non-zero", which is what a stale `401` leaves behind — so the
3500    /// permissions `403` below is a permissions answer whatever happened minutes
3501    /// ago, and it is the *response*, not the history, that decides.
3502    #[tokio::test]
3503    async fn a_stale_401_does_not_turn_a_later_permissions_403_into_a_lockout() {
3504        let server = MockServer::start().await;
3505        // The first request ends in a 404, which leaves the 401 count set
3506        // because only a 2xx clears it.
3507        Mock::given(method("GET"))
3508            .and(path("/orgs/acme/actions/runners"))
3509            .respond_with(Script::new(vec![
3510                ResponseTemplate::new(401),
3511                ResponseTemplate::new(404).set_body_json(json!({"message": "Not Found"})),
3512            ]))
3513            .mount(&server)
3514            .await;
3515        Mock::given(method("GET"))
3516            .and(path("/user/installations"))
3517            .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3518            .mount(&server)
3519            .await;
3520        // Minutes later, a different call is denied for a missing permission.
3521        Mock::given(method("POST"))
3522            .and(path("/orgs/acme/actions/runners/generate-jitconfig"))
3523            .respond_with(
3524                ResponseTemplate::new(403)
3525                    .set_body_json(json!({"message": "Resource not accessible by integration"})),
3526            )
3527            .mount(&server)
3528            .await;
3529
3530        let client = client(&server, Arc::new(TestClock::default()));
3531        let err = client
3532            .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3533            .await
3534            .expect_err("404");
3535        assert!(
3536            matches!(err, GithubError::Status { status: 404, .. }),
3537            "{err:?}"
3538        );
3539
3540        let err = client
3541            .send(&ApiRequest::new(
3542                Method::POST,
3543                "/orgs/acme/actions/runners/generate-jitconfig",
3544            ))
3545            .await
3546            .expect_err("403");
3547
3548        assert!(
3549            matches!(err, GithubError::Forbidden { .. }),
3550            "a fresh first-attempt 403 is a permissions answer, not a lockout: {err:?}"
3551        );
3552        assert!(!err.is_lockout());
3553        assert!(
3554            !client.is_locked_out(),
3555            "a stale 401 must not be able to silence the client for a minute"
3556        );
3557    }
3558
3559    /// GitHub's own rate limit is not an answer about the credential, and must
3560    /// not be reported as one. `classify` reached the `403` branch before
3561    /// anything looked at the rate-limit headers, so a primary rate limit
3562    /// arriving during a `401` storm was announced as an authentication lockout
3563    /// with the message "the credential itself is not the problem" — about a
3564    /// response that never mentioned the credential.
3565    #[tokio::test]
3566    async fn a_rate_limited_403_is_not_reported_as_an_authentication_lockout() {
3567        let server = MockServer::start().await;
3568        Mock::given(method("GET"))
3569            .and(path("/orgs/acme/actions/runners"))
3570            .respond_with(Script::new(vec![
3571                ResponseTemplate::new(401),
3572                ResponseTemplate::new(403)
3573                    .insert_header("x-ratelimit-remaining", "0")
3574                    .insert_header("x-ratelimit-reset", "1787270460")
3575                    .insert_header("retry-after", "30")
3576                    .set_body_json(json!({"message": "API rate limit exceeded"})),
3577            ]))
3578            .mount(&server)
3579            .await;
3580        Mock::given(method("GET"))
3581            .and(path("/user/installations"))
3582            .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3583            .mount(&server)
3584            .await;
3585
3586        let client = client(&server, Arc::new(TestClock::default()));
3587        let err = client
3588            .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3589            .await
3590            .expect_err("rate limited");
3591
3592        assert!(
3593            matches!(err, GithubError::Forbidden { .. }),
3594            "a rate limit is not an authentication outcome: {err:?}"
3595        );
3596        assert!(!err.is_lockout());
3597        assert!(!err.is_authentication());
3598        assert!(
3599            !client.is_locked_out(),
3600            "a rate limit must not latch this crate's authentication back-off"
3601        );
3602
3603        // And `c3` gets the evidence it needs to apply the policy that is its
3604        // own, without editing this file.
3605        let evidence = err
3606            .rate_limit()
3607            .expect("the headers survived classification");
3608        assert_eq!(evidence.remaining, Some(0));
3609        assert_eq!(evidence.reset_unix_secs, Some(1_787_270_460));
3610        assert_eq!(evidence.retry_after, Some(Duration::from_secs(30)));
3611    }
3612
3613    /// The same claim for the variant `429` lands in.
3614    #[tokio::test]
3615    async fn a_429_carries_its_retry_after_across_the_c2_c3_seam() {
3616        let server = MockServer::start().await;
3617        Mock::given(method("GET"))
3618            .and(path("/orgs/acme/actions/runners"))
3619            .respond_with(
3620                ResponseTemplate::new(429)
3621                    .insert_header("retry-after", "17")
3622                    .insert_header("x-ratelimit-remaining", "0")
3623                    .set_body_json(json!({"message": "You have exceeded a secondary rate limit"})),
3624            )
3625            .mount(&server)
3626            .await;
3627
3628        let client = client(&server, Arc::new(TestClock::default()));
3629        let err = client
3630            .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3631            .await
3632            .expect_err("429");
3633
3634        assert!(
3635            matches!(err, GithubError::Status { status: 429, .. }),
3636            "{err:?}"
3637        );
3638        assert_eq!(
3639            err.retry_after(),
3640            Some(Duration::from_secs(17)),
3641            "destroying this header is what made `c3`'s Definition of Done unmeetable"
3642        );
3643        assert_eq!(
3644            err.headers().and_then(|h| h.get("x-ratelimit-remaining")),
3645            Some(&reqwest::header::HeaderValue::from_static("0"))
3646        );
3647    }
3648
3649    /// A back-off is a safety mechanism, and this one had both failure modes at
3650    /// once: no ceiling, so `Retry-After: 86400` latched a silent twenty-four
3651    /// hour outage; and `TimeDelta::from_std(...).ok()` on a value too large to
3652    /// convert, which yielded `until = None` — *not locked out at all*, the
3653    /// exact inverse of the requirement, reachable by a header alone.
3654    #[tokio::test]
3655    async fn an_extreme_retry_after_is_clamped_and_never_fails_open() {
3656        async fn lockout_for(header: &str) -> (GithubError, bool, Option<Duration>) {
3657            let server = MockServer::start().await;
3658            Mock::given(method("GET"))
3659                .and(path("/orgs/acme/actions/runners"))
3660                .respond_with(Script::new(vec![
3661                    ResponseTemplate::new(401),
3662                    ResponseTemplate::new(403).insert_header("retry-after", header),
3663                ]))
3664                .mount(&server)
3665                .await;
3666            Mock::given(method("GET"))
3667                .and(path("/user/installations"))
3668                .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3669                .mount(&server)
3670                .await;
3671
3672            let client = AuthenticatedClient::new(
3673                Endpoints::for_test_server(&server.uri()).unwrap(),
3674                UserAccessToken::new(SecretString::from(FIXTURE_TOKEN)),
3675                Arc::new(TestClock::default()),
3676            )
3677            .unwrap();
3678            let err = client
3679                .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3680                .await
3681                .expect_err("403 after a 401");
3682            let locked = client.is_locked_out();
3683            let remaining = client.lockout_remaining();
3684            (err, locked, remaining)
3685        }
3686
3687        // A day-long back-off is clamped to the ceiling.
3688        let (err, locked, remaining) = lockout_for("86400").await;
3689        let GithubError::AuthenticationLockout { retry_after } = &err else {
3690            panic!("expected a lockout, got {err:?}");
3691        };
3692        assert_eq!(
3693            *retry_after, MAX_LOCKOUT_BACKOFF,
3694            "an unclamped Retry-After lets a remote party decide how long this product \
3695             stays down"
3696        );
3697        assert!(locked);
3698        assert!(remaining.is_some_and(|r| r <= MAX_LOCKOUT_BACKOFF));
3699
3700        // A value too large for `chrono` must still lock out. Before the fix
3701        // this produced `until = None`: the more extreme the header, the less
3702        // protection it bought.
3703        let (err, locked, remaining) = lockout_for(&u64::MAX.to_string()).await;
3704        assert!(err.is_lockout(), "{err:?}");
3705        assert!(
3706            locked,
3707            "an absurd Retry-After must not mean `not locked out at all` — that fails open"
3708        );
3709        assert!(remaining.is_some_and(|r| r <= MAX_LOCKOUT_BACKOFF));
3710    }
3711
3712    /// The lockout's own contract is "this client issues no HTTP at all", and
3713    /// `revalidate` is HTTP. It documented an `AuthenticationLockout` it could
3714    /// never return, which made the one direct entry point into the probe the
3715    /// single exception to the rule.
3716    #[tokio::test]
3717    async fn a_direct_revalidation_is_refused_while_the_lockout_is_backing_off() {
3718        let server = MockServer::start().await;
3719        Mock::given(method("GET"))
3720            .and(path("/orgs/acme/actions/runners"))
3721            .respond_with(Script::new(vec![
3722                ResponseTemplate::new(401),
3723                ResponseTemplate::new(403).insert_header("retry-after", "60"),
3724            ]))
3725            .mount(&server)
3726            .await;
3727        Mock::given(method("GET"))
3728            .and(path("/user/installations"))
3729            .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3730            .mount(&server)
3731            .await;
3732
3733        let client = client(&server, Arc::new(TestClock::default()));
3734        client
3735            .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3736            .await
3737            .expect_err("locks out");
3738        assert!(client.is_locked_out());
3739
3740        let before = server.received_requests().await.unwrap().len();
3741        let err = client
3742            .revalidate()
3743            .await
3744            .expect_err("the documented lockout error is now reachable");
3745        assert!(err.is_lockout(), "{err:?}");
3746        assert_eq!(
3747            server.received_requests().await.unwrap().len(),
3748            before,
3749            "a locked-out client opens no socket, and the probe is not an exception"
3750        );
3751    }
3752
3753    /// The position rule fixed `classify` and left the same defect one function
3754    /// over, behind a comment asserting it could not happen: "the probe only
3755    /// ever runs after a `401`, so it is always in the retry position". Making
3756    /// [`AuthenticatedClient::revalidate`] public — the previous round's own
3757    /// change — is exactly what made that untrue.
3758    ///
3759    /// The sequence is the agent's, not a contrivance. A request 401s, the probe
3760    /// says the credential is fine, the retry answers `404` — which does *not*
3761    /// reset the counter, by design. Minutes later `f1` renders `auth status`,
3762    /// which probes directly, and the probe meets an ordinary permissions `403`.
3763    /// A stale `401` then latched a sixty-second client-wide lockout and told
3764    /// the operator to wait, when the real answer was a missing grant.
3765    #[tokio::test]
3766    async fn a_directly_requested_probe_does_not_latch_a_lockout_from_a_stale_401() {
3767        let server = MockServer::start().await;
3768        Mock::given(method("GET"))
3769            .and(path("/orgs/acme/actions/runners"))
3770            .respond_with(Script::new(vec![
3771                ResponseTemplate::new(401),
3772                // The retry misses. A `404` leaves `consecutive_unauthorized`
3773                // set, which is the whole premise of the position rule.
3774                ResponseTemplate::new(404).set_body_json(json!({"message": "Not Found"})),
3775            ]))
3776            .mount(&server)
3777            .await;
3778        Mock::given(method("GET"))
3779            .and(path("/user/installations"))
3780            .respond_with(Script::new(vec![
3781                // The probe that accompanies the 401 above.
3782                ResponseTemplate::new(200).set_body_json(installations_body(&[])),
3783                // The direct probe, minutes later: a plain permissions answer,
3784                // with no `retry-after` and a message that names a grant.
3785                ResponseTemplate::new(403)
3786                    .set_body_json(json!({"message": "Resource not accessible by integration"})),
3787            ]))
3788            .mount(&server)
3789            .await;
3790
3791        let clock = Arc::new(TestClock::default());
3792        let client = client(&server, clock.clone());
3793        client
3794            .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3795            .await
3796            .expect_err("the retry 404s");
3797        assert!(
3798            !client.is_locked_out(),
3799            "a 404 on the retry is not a lockout"
3800        );
3801
3802        clock.advance_secs(300);
3803        let outcome = client
3804            .revalidate()
3805            .await
3806            .expect("a direct probe is not a lockout error");
3807
3808        assert_eq!(
3809            outcome,
3810            Revalidation::Unavailable,
3811            "a 403 on the probe teaches this client nothing about the credential"
3812        );
3813        assert!(
3814            !client.is_locked_out(),
3815            "a caller-initiated probe is a *first* attempt, not the retry that follows a 401: \
3816             latching here converts a stale 401 into a 60-second client-wide outage and \
3817             reports a missing permission as `the credential is fine, please wait`"
3818        );
3819        assert_eq!(client.lockout_remaining(), None);
3820    }
3821
3822    /// The square the other three leave empty, and the one where a defect in the
3823    /// composition would hide.
3824    ///
3825    /// Covered elsewhere: a first-attempt continuation through `send`, a
3826    /// first-attempt permissions `403` through `send`, and a direct probe
3827    /// meeting a permissions `403` (immediately above). A direct probe meeting a
3828    /// *continuation-shaped* `403` is the fourth square, and it is the
3829    /// composition point of the two rules that pull in opposite directions —
3830    /// the narrowing to `Attempt::First` that fixed the stale-`401` lockout, and
3831    /// the continuation rule that re-widens `First` on GitHub's own evidence.
3832    ///
3833    /// If the narrowing swallowed the continuation here, every other test in
3834    /// this file would still pass, and `f1`'s `auth status` would poll a
3835    /// credential GitHub had asked it to leave alone — reporting each refusal as
3836    /// a missing grant.
3837    #[tokio::test]
3838    async fn a_directly_requested_probe_latches_a_continuation_shaped_lockout() {
3839        let server = MockServer::start().await;
3840        Mock::given(method("GET"))
3841            .and(path("/user/installations"))
3842            // The continuation's signature: `retry-after`, and a body with no
3843            // message for `error_message` to find.
3844            .respond_with(ResponseTemplate::new(403).insert_header("retry-after", "60"))
3845            .mount(&server)
3846            .await;
3847
3848        let client = client(&server, Arc::new(TestClock::default()));
3849
3850        // No preceding traffic whatsoever: a first attempt in the strongest
3851        // sense, which is exactly the position the narrowed rule refused to
3852        // latch in.
3853        let err = client
3854            .revalidate()
3855            .await
3856            .expect_err("a probe that latches a lockout reports it rather than `Unavailable`");
3857        assert!(
3858            err.is_lockout(),
3859            "`revalidate` latched a client-wide lockout and must say so; answering \
3860             `Ok(Unavailable)` leaves `f1` to discover a 15-minute outage through a separate \
3861             `is_locked_out()` call it has no reason to make: {err:?}"
3862        );
3863        assert!(
3864            client.is_locked_out(),
3865            "a 403 carrying `retry-after` with no message is GitHub continuing a lockout, \
3866             whoever asked for the request that met it"
3867        );
3868        assert_eq!(client.lockout_remaining(), Some(Duration::from_secs(60)));
3869
3870        // And the back-off is real, not just a renamed error.
3871        let before = server.received_requests().await.unwrap().len();
3872        let err = client.revalidate().await.expect_err("still locked out");
3873        assert!(err.is_lockout(), "{err:?}");
3874        assert_eq!(
3875            server.received_requests().await.unwrap().len(),
3876            before,
3877            "latching must actually stop traffic"
3878        );
3879    }
3880
3881    /// [`retry_after`] parses integer seconds only; RFC 9110 §10.2.3 also
3882    /// permits an HTTP-date. Detection used to gate on that parse succeeding, so
3883    /// a date-form `Retry-After` was not recognised as a continuation at all and
3884    /// the hole the continuation rule exists to close reopened for that shape —
3885    /// silently, because the response leaves as an ordinary `Forbidden`.
3886    ///
3887    /// GitHub sends integer seconds in practice. This pins the crate to not
3888    /// depending on that, and records where the two halves part company:
3889    /// presence decides *whether* it is a lockout, the integer parse decides
3890    /// only *how long*, and `latch_lockout` already had a default for the header
3891    /// it could not read.
3892    #[tokio::test]
3893    async fn a_date_form_retry_after_is_still_recognised_as_a_continuation() {
3894        let server = MockServer::start().await;
3895        Mock::given(method("GET"))
3896            .and(path("/user/installations"))
3897            .respond_with(
3898                ResponseTemplate::new(403)
3899                    .insert_header("retry-after", "Wed, 21 Oct 2026 07:28:00 GMT"),
3900            )
3901            .mount(&server)
3902            .await;
3903
3904        let client = client(&server, Arc::new(TestClock::default()));
3905        let err = client
3906            .revalidate()
3907            .await
3908            .expect_err("a date-form `retry-after` is still GitHub asking to be left alone");
3909        assert!(
3910            err.is_lockout(),
3911            "gating detection on an integer parse hands the continuation bug back for every \
3912             lockout GitHub chose to date-stamp: {err:?}"
3913        );
3914        assert_eq!(
3915            client.lockout_remaining(),
3916            Some(DEFAULT_LOCKOUT_BACKOFF),
3917            "the date form is recognised for detection; the duration falls back to the \
3918             default, which is what `latch_lockout` already did with a header it could not \
3919             parse as seconds"
3920        );
3921    }
3922
3923    /// The narrowing that fixed the stale-`401` lockout opened a hole at the
3924    /// other end of the same back-off.
3925    ///
3926    /// While GitHub is still locking the credential out after the back-off
3927    /// elapses, the next request is a *first* attempt by construction — this
3928    /// client's own retry never happened, because the request never reached the
3929    /// wire. So the position rule declined to call it a lockout and `classify`
3930    /// fell through to [`GithubError::Forbidden`], whose documented reading is
3931    /// "the App installation does not grant it". The client then stopped backing
3932    /// off entirely and hammered a credential GitHub had asked it to leave
3933    /// alone, which is the exact inverse of "backs off without retrying".
3934    ///
3935    /// A continuation is distinguishable from a permissions answer without any
3936    /// counter: GitHub sends `retry-after` and no message body for the lockout,
3937    /// and a message and no `retry-after` for a permissions refusal.
3938    #[tokio::test]
3939    async fn a_lockout_outliving_its_backoff_re_latches_instead_of_reporting_a_permissions_answer()
3940    {
3941        let server = MockServer::start().await;
3942        Mock::given(method("GET"))
3943            .and(path("/orgs/acme/actions/runners"))
3944            .respond_with(Script::new(vec![
3945                ResponseTemplate::new(401),
3946                // The retry: the lockout latches here, in the retry position.
3947                ResponseTemplate::new(403).insert_header("retry-after", "60"),
3948                // The continuation, once the back-off has elapsed. Same shape,
3949                // first position.
3950                ResponseTemplate::new(403).insert_header("retry-after", "60"),
3951            ]))
3952            .mount(&server)
3953            .await;
3954        Mock::given(method("GET"))
3955            .and(path("/user/installations"))
3956            .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3957            .mount(&server)
3958            .await;
3959
3960        let clock = Arc::new(TestClock::default());
3961        let client = client(&server, clock.clone());
3962        let err = client
3963            .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3964            .await
3965            .expect_err("403 on the retry");
3966        assert!(err.is_lockout(), "{err:?}");
3967        assert!(client.is_locked_out());
3968
3969        // The back-off elapses with GitHub unchanged.
3970        clock.advance_secs(61);
3971        assert!(!client.is_locked_out(), "the back-off has run out");
3972
3973        let err = client
3974            .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3975            .await
3976            .expect_err("GitHub is still locking the credential out");
3977
3978        assert!(
3979            err.is_lockout(),
3980            "a 403 carrying `retry-after` with no message is GitHub continuing the lockout, \
3981             not the App installation refusing a permission; reporting `Forbidden` here \
3982             tells the operator to fix a grant that is not missing: {err:?}"
3983        );
3984        assert!(
3985            client.is_locked_out(),
3986            "`backs off without retrying` fails for any lockout that outlives one back-off \
3987             if the continuation does not re-latch"
3988        );
3989        let GithubError::AuthenticationLockout { retry_after } = err else {
3990            unreachable!("asserted above")
3991        };
3992        assert_eq!(
3993            retry_after,
3994            Duration::from_secs(60),
3995            "the continuation's own `retry-after` sets the new back-off"
3996        );
3997
3998        // And the next request is suppressed before a socket is opened, which is
3999        // the property the whole back-off exists for.
4000        let before = server.received_requests().await.unwrap().len();
4001        let err = client
4002            .send(&ApiRequest::get("/orgs/acme/actions/runners"))
4003            .await
4004            .expect_err("still locked out");
4005        assert!(err.is_lockout(), "{err:?}");
4006        assert_eq!(
4007            server.received_requests().await.unwrap().len(),
4008            before,
4009            "re-latching must actually stop traffic, not merely rename the error"
4010        );
4011    }
4012
4013    /// A permissions `403` on a first attempt is still a permissions answer, and
4014    /// the continuation rule above must not swallow it. This is the test that
4015    /// keeps that rule from becoming "every 403 is a lockout".
4016    #[tokio::test]
4017    async fn a_first_attempt_permissions_403_is_still_reported_as_forbidden() {
4018        let server = MockServer::start().await;
4019        Mock::given(method("GET"))
4020            .and(path("/orgs/acme/actions/runners"))
4021            .respond_with(
4022                ResponseTemplate::new(403)
4023                    .set_body_json(json!({"message": "Resource not accessible by integration"})),
4024            )
4025            .mount(&server)
4026            .await;
4027
4028        let client = client(&server, Arc::new(TestClock::default()));
4029        let err = client
4030            .send(&ApiRequest::get("/orgs/acme/actions/runners"))
4031            .await
4032            .expect_err("403");
4033
4034        assert!(
4035            matches!(err, GithubError::Forbidden { .. }),
4036            "a message and no `retry-after` is GitHub naming a missing grant: {err:?}"
4037        );
4038        assert!(!client.is_locked_out());
4039    }
4040
4041    /// The `consecutive_unauthorized > 0` conjunct that used to sit alongside
4042    /// the position rule added no signal — `Attempt::Retry` already implies this
4043    /// request's own `401` incremented the counter — and added a fail-open race:
4044    /// any concurrent success `store(0)`s the counter between the `401` and the
4045    /// retry, and a real lockout is then reported as a permissions answer.
4046    ///
4047    /// The race is driven directly rather than by scheduling two requests and
4048    /// hoping: `store(0)` is the *only* thing the concurrent success contributes,
4049    /// so performing it between the `401` and the classification reproduces the
4050    /// race deterministically and on every run.
4051    #[tokio::test]
4052    async fn a_concurrent_success_cannot_downgrade_a_lockout_to_a_permissions_answer() {
4053        let server = MockServer::start().await;
4054        let client = client(&server, Arc::new(TestClock::default()));
4055
4056        // This request's own 401 has landed: the retry position is established.
4057        client
4058            .consecutive_unauthorized
4059            .fetch_add(1, Ordering::SeqCst);
4060        // ... and a request on another task succeeds in the same instant.
4061        client.consecutive_unauthorized.store(0, Ordering::SeqCst);
4062
4063        let mut headers = HeaderMap::new();
4064        headers.insert("retry-after", "60".parse().unwrap());
4065        let lockout = ApiResponse {
4066            status: StatusCode::FORBIDDEN,
4067            headers,
4068            body: Vec::new(),
4069        };
4070
4071        assert!(
4072            client.is_lockout_403(&lockout, Attempt::Retry),
4073            "`Attempt::Retry` already means this request's own 401 incremented the counter, so \
4074             reading the counter again adds no signal and only lets an unrelated success \
4075             downgrade a real lockout to `Forbidden`"
4076        );
4077
4078        // The counter must stay irrelevant in the other direction too: a
4079        // permissions `403` on a first attempt is not a lockout however many
4080        // `401`s are on the count.
4081        client.consecutive_unauthorized.store(7, Ordering::SeqCst);
4082        let permissions = ApiResponse {
4083            status: StatusCode::FORBIDDEN,
4084            headers: HeaderMap::new(),
4085            body: br#"{"message":"Resource not accessible by integration"}"#.to_vec(),
4086        };
4087        assert!(!client.is_lockout_403(&permissions, Attempt::First));
4088    }
4089
4090    // -- installation discovery ---------------------------------------------
4091
4092    #[tokio::test]
4093    async fn discovery_returns_the_reachable_repository_and_organization_set() {
4094        let server = MockServer::start().await;
4095        Mock::given(method("GET"))
4096            .and(path("/user/installations"))
4097            .respond_with(
4098                ResponseTemplate::new(200).set_body_json(installations_body(&[
4099                    (11, "IvanMurzak", "User", "selected"),
4100                    (22, "Tap-Top-Fun", "Organization", "all"),
4101                ])),
4102            )
4103            .mount(&server)
4104            .await;
4105        Mock::given(method("GET"))
4106            .and(path("/user/installations/11/repositories"))
4107            .respond_with(
4108                ResponseTemplate::new(200)
4109                    .set_body_json(repositories_body(&["IvanMurzak/GitHub-Runner-Scaler-UI"])),
4110            )
4111            .mount(&server)
4112            .await;
4113        Mock::given(method("GET"))
4114            .and(path("/user/installations/22/repositories"))
4115            .respond_with(
4116                ResponseTemplate::new(200)
4117                    .set_body_json(repositories_body(&["Tap-Top-Fun/game", "Tap-Top-Fun/site"])),
4118            )
4119            .mount(&server)
4120            .await;
4121
4122        let client = client(&server, Arc::new(TestClock::default()));
4123        let discovery = client.discover_installations(&app()).await.unwrap();
4124
4125        let targets = discovery.targets().expect("installed");
4126        assert_eq!(
4127            targets
4128                .repositories()
4129                .iter()
4130                .map(ToString::to_string)
4131                .collect::<Vec<_>>(),
4132            [
4133                "IvanMurzak/GitHub-Runner-Scaler-UI",
4134                "Tap-Top-Fun/game",
4135                "Tap-Top-Fun/site"
4136            ]
4137        );
4138        assert_eq!(
4139            targets
4140                .organizations()
4141                .iter()
4142                .map(ToString::to_string)
4143                .collect::<Vec<_>>(),
4144            ["Tap-Top-Fun"],
4145            "a User account is not an organization target"
4146        );
4147        assert!(discovery.install_url().is_none());
4148    }
4149
4150    #[tokio::test]
4151    async fn an_over_broad_installation_is_visible_rather_than_assumed() {
4152        let server = MockServer::start().await;
4153        Mock::given(method("GET"))
4154            .and(path("/user/installations"))
4155            .respond_with(
4156                ResponseTemplate::new(200).set_body_json(installations_body(&[
4157                    (11, "IvanMurzak", "User", "selected"),
4158                    (22, "Tap-Top-Fun", "Organization", "all"),
4159                ])),
4160            )
4161            .mount(&server)
4162            .await;
4163        Mock::given(method("GET"))
4164            .and(path("/user/installations/11/repositories"))
4165            .respond_with(ResponseTemplate::new(200).set_body_json(repositories_body(&["a/b"])))
4166            .mount(&server)
4167            .await;
4168        Mock::given(method("GET"))
4169            .and(path("/user/installations/22/repositories"))
4170            .respond_with(ResponseTemplate::new(200).set_body_json(repositories_body(&["c/d"])))
4171            .mount(&server)
4172            .await;
4173
4174        let client = client(&server, Arc::new(TestClock::default()));
4175        let targets = client
4176            .discover_installations(&app())
4177            .await
4178            .unwrap()
4179            .targets()
4180            .cloned()
4181            .expect("installed");
4182
4183        let over_broad = targets.over_broad();
4184        assert_eq!(over_broad.len(), 1);
4185        assert_eq!(over_broad[0].account.login(), "Tap-Top-Fun");
4186        assert!(over_broad[0].is_over_broad());
4187        assert_eq!(
4188            over_broad[0].repository_selection,
4189            RepositorySelection::All,
4190            "`repository_selection: all` reaches repositories created later too"
4191        );
4192        assert!(
4193            targets.installations().iter().any(|i| i
4194                .permissions
4195                .iter()
4196                .any(|(k, v)| k == "administration" && v == "write")),
4197            "the grant GitHub reports is surfaced verbatim, not assumed from the design"
4198        );
4199    }
4200
4201    #[tokio::test]
4202    async fn discovery_returns_the_installation_url_when_the_set_is_empty() {
4203        let server = MockServer::start().await;
4204        Mock::given(method("GET"))
4205            .and(path("/user/installations"))
4206            .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
4207            .mount(&server)
4208            .await;
4209
4210        let client = client(&server, Arc::new(TestClock::default()));
4211        let discovery = client.discover_installations(&app()).await.unwrap();
4212
4213        let url = discovery
4214            .install_url()
4215            .expect("an empty set must yield the installation URL");
4216        assert_eq!(url.path(), "/apps/runner-manager/installations/new");
4217        assert!(discovery.targets().is_none());
4218    }
4219
4220    #[tokio::test]
4221    async fn an_installation_that_reaches_no_repository_is_still_not_installed() {
4222        let server = MockServer::start().await;
4223        Mock::given(method("GET"))
4224            .and(path("/user/installations"))
4225            .respond_with(
4226                ResponseTemplate::new(200).set_body_json(installations_body(&[(
4227                    11,
4228                    "IvanMurzak",
4229                    "User",
4230                    "selected",
4231                )])),
4232            )
4233            .mount(&server)
4234            .await;
4235        Mock::given(method("GET"))
4236            .and(path("/user/installations/11/repositories"))
4237            .respond_with(ResponseTemplate::new(200).set_body_json(repositories_body(&[])))
4238            .mount(&server)
4239            .await;
4240
4241        let client = client(&server, Arc::new(TestClock::default()));
4242        let discovery = client.discover_installations(&app()).await.unwrap();
4243        assert!(
4244            discovery.install_url().is_some(),
4245            "a user installation that selected no repository reaches nothing"
4246        );
4247    }
4248
4249    #[tokio::test]
4250    async fn discovery_follows_every_page_rather_than_trusting_the_first() {
4251        let server = MockServer::start().await;
4252        let next = format!("<{}/user/installations?page=2>; rel=\"next\"", server.uri());
4253        Mock::given(method("GET"))
4254            .and(path("/user/installations"))
4255            .respond_with(Script::new(vec![
4256                ResponseTemplate::new(200)
4257                    .set_body_json(installations_body(&[(
4258                        11,
4259                        "one",
4260                        "Organization",
4261                        "selected",
4262                    )]))
4263                    .insert_header("link", next.as_str()),
4264                ResponseTemplate::new(200).set_body_json(installations_body(&[(
4265                    22,
4266                    "two",
4267                    "Organization",
4268                    "selected",
4269                )])),
4270            ]))
4271            .mount(&server)
4272            .await;
4273        Mock::given(method("GET"))
4274            .and(path("/user/installations/11/repositories"))
4275            .respond_with(ResponseTemplate::new(200).set_body_json(repositories_body(&["one/a"])))
4276            .mount(&server)
4277            .await;
4278        Mock::given(method("GET"))
4279            .and(path("/user/installations/22/repositories"))
4280            .respond_with(ResponseTemplate::new(200).set_body_json(repositories_body(&["two/b"])))
4281            .mount(&server)
4282            .await;
4283
4284        let client = client(&server, Arc::new(TestClock::default()));
4285        let targets = client
4286            .discover_installations(&app())
4287            .await
4288            .unwrap()
4289            .targets()
4290            .cloned()
4291            .expect("installed");
4292        assert_eq!(
4293            targets
4294                .organizations()
4295                .iter()
4296                .map(ToString::to_string)
4297                .collect::<Vec<_>>(),
4298            ["one", "two"],
4299            "the second page must not be dropped"
4300        );
4301    }
4302
4303    /// GitHub's published `installation` schema types `account` as **nullable**,
4304    /// and as either a simple-user *or* an enterprise — which carries
4305    /// `slug`/`name` where a user carries `login`. A required `RawAccount` with
4306    /// a required `login` made either shape a hard `response.json()` failure,
4307    /// which takes down all of `discover_installations`, which is all of
4308    /// `auth status`. One unusual installation must not blind the command that
4309    /// exists to show the user what their credential can reach.
4310    #[tokio::test]
4311    async fn an_installation_with_a_null_or_enterprise_account_does_not_fail_the_whole_decode() {
4312        let server = MockServer::start().await;
4313        Mock::given(method("GET"))
4314            .and(path("/user/installations"))
4315            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
4316                "total_count": 3,
4317                "installations": [
4318                    // Nullable, per the published schema.
4319                    { "id": 10, "account": null, "repository_selection": "selected" },
4320                    // An enterprise: no `login` at all.
4321                    {
4322                        "id": 20,
4323                        "account": { "slug": "acme-enterprise", "name": "Acme Inc" },
4324                        "repository_selection": "selected"
4325                    },
4326                    // And an ordinary user alongside them.
4327                    {
4328                        "id": 30,
4329                        "account": { "login": "IvanMurzak", "type": "User" },
4330                        "repository_selection": "selected"
4331                    }
4332                ]
4333            })))
4334            .mount(&server)
4335            .await;
4336        for (id, repo) in [(20_u64, "acme-enterprise/tools"), (30, "IvanMurzak/app")] {
4337            Mock::given(method("GET"))
4338                .and(path(format!("/user/installations/{id}/repositories")))
4339                .respond_with(ResponseTemplate::new(200).set_body_json(repositories_body(&[repo])))
4340                .mount(&server)
4341                .await;
4342        }
4343
4344        let client = client(&server, Arc::new(TestClock::default()));
4345        let targets = client
4346            .discover_installations(&app())
4347            .await
4348            .expect("one odd account must not fail the whole discovery")
4349            .targets()
4350            .cloned()
4351            .expect("installed");
4352
4353        // Membership rather than order: what matters here is that neither
4354        // installation was lost, not how `OwnerRepo` collates.
4355        let reached = targets
4356            .repositories()
4357            .iter()
4358            .map(ToString::to_string)
4359            .collect::<Vec<_>>();
4360        assert!(
4361            reached.contains(&"acme-enterprise/tools".to_string()),
4362            "the enterprise installation is named from `slug` rather than dropped: {reached:?}"
4363        );
4364        assert!(
4365            reached.contains(&"IvanMurzak/app".to_string()),
4366            "the ordinary installation alongside it survives too: {reached:?}"
4367        );
4368        assert_eq!(reached.len(), 2);
4369        assert_eq!(
4370            targets.installations().len(),
4371            2,
4372            "the null account is skipped, and only it"
4373        );
4374        assert_eq!(
4375            targets.skipped(),
4376            1,
4377            "the skip is the right trade, but it must travel with the answer: everything the \
4378             skipped installation reaches is missing from the lists above, and a short list \
4379             reads exactly like a complete one"
4380        );
4381
4382        // The enterprise is labelled an enterprise. It used to fall through to
4383        // `User`, so `auth status` told the operator their enterprise was a
4384        // personal account.
4385        let enterprise = targets
4386            .installations()
4387            .iter()
4388            .find(|i| i.id == 20)
4389            .expect("the enterprise installation survived");
4390        assert_eq!(
4391            enterprise.account,
4392            InstallationAccount::Enterprise("acme-enterprise".to_string()),
4393            "an account with no `login` that names itself through `slug` is an enterprise, \
4394             and calling it a user is a wrong statement about the operator's own account"
4395        );
4396        assert_eq!(enterprise.account.kind(), "enterprise");
4397        assert!(
4398            enterprise.account.organization().is_none(),
4399            "an enterprise is not an organization target: `GET /orgs/{{org}}/actions/runners` \
4400             does not accept one, so contributing nothing to `organizations()` is correct"
4401        );
4402        assert!(
4403            !targets
4404                .organizations()
4405                .iter()
4406                .any(|o| o.as_str() == "acme-enterprise"),
4407            "and it must not be smuggled in as one either"
4408        );
4409    }
4410
4411    /// The skip is right; the verdict flip was not.
4412    ///
4413    /// A null-account installation that is the *only* installation used to
4414    /// collapse to `NotInstalled`, so `auth status` handed an operator who **is**
4415    /// installed the "install the App" URL — a wrong remediation on the only
4416    /// authentication path there is, contradicted by nothing but a `warn!`.
4417    #[tokio::test]
4418    async fn a_credential_whose_only_installation_was_skipped_is_not_reported_as_not_installed() {
4419        let server = MockServer::start().await;
4420        Mock::given(method("GET"))
4421            .and(path("/user/installations"))
4422            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
4423                "total_count": 1,
4424                "installations": [
4425                    { "id": 10, "account": null, "repository_selection": "selected" }
4426                ]
4427            })))
4428            .mount(&server)
4429            .await;
4430
4431        let client = client(&server, Arc::new(TestClock::default()));
4432        let discovery = client.discover_installations(&app()).await.unwrap();
4433
4434        assert_eq!(
4435            discovery,
4436            InstallationDiscovery::Indeterminate { skipped: 1 },
4437            "GitHub reported an installation; this client could not describe it. That is not \
4438             the same answer as `not installed`, and only one of the two is fixed by \
4439             installing the App"
4440        );
4441        assert_eq!(
4442            discovery.install_url(),
4443            None,
4444            "offering the install URL here is the wrong remediation, and putting it one field \
4445             over from the right verdict would just relocate the defect"
4446        );
4447        assert_eq!(discovery.skipped(), 1);
4448        assert!(discovery.targets().is_none());
4449    }
4450
4451    /// The other side of the same rule: with nothing skipped, an empty reach is
4452    /// still an empty reach, and the install URL is still the remediation.
4453    #[tokio::test]
4454    async fn an_empty_reach_with_nothing_skipped_is_still_not_installed() {
4455        let server = MockServer::start().await;
4456        Mock::given(method("GET"))
4457            .and(path("/user/installations"))
4458            .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
4459            .mount(&server)
4460            .await;
4461
4462        let client = client(&server, Arc::new(TestClock::default()));
4463        let discovery = client.discover_installations(&app()).await.unwrap();
4464
4465        assert!(discovery.install_url().is_some(), "{discovery:?}");
4466        assert_eq!(discovery.skipped(), 0);
4467    }
4468
4469    /// A `Link: rel="next"` that points back at the page it arrived on is an
4470    /// infinite loop inside the agent's reconciliation loop — the one place in
4471    /// this product that must not be able to wedge. The ceiling is what makes
4472    /// this test terminate at all.
4473    #[tokio::test]
4474    async fn a_self_referential_link_header_stops_at_the_page_ceiling() {
4475        let server = MockServer::start().await;
4476        let self_link = format!("<{}/user/installations?page=2>; rel=\"next\"", server.uri());
4477        Mock::given(method("GET"))
4478            .and(path("/user/installations"))
4479            .respond_with(
4480                ResponseTemplate::new(200)
4481                    .set_body_json(installations_body(&[]))
4482                    .insert_header("link", self_link.as_str()),
4483            )
4484            .mount(&server)
4485            .await;
4486
4487        let client = client(&server, Arc::new(TestClock::default()));
4488        let discovery = client
4489            .discover_installations(&app())
4490            .await
4491            .expect("the ceiling is what makes this return at all");
4492
4493        assert!(
4494            discovery.install_url().is_some(),
4495            "no installation was found"
4496        );
4497        assert_eq!(
4498            server.received_requests().await.unwrap().len(),
4499            MAX_PAGES,
4500            "pagination must stop at the ceiling rather than follow the loop forever"
4501        );
4502    }
4503
4504    #[test]
4505    fn a_link_header_yields_only_the_next_relation() {
4506        let header = "<https://api.github.com/user/installations?page=3>; rel=\"next\", \
4507                      <https://api.github.com/user/installations?page=9>; rel=\"last\"";
4508        assert_eq!(
4509            parse_link_next(header).map(|u| u.to_string()),
4510            Some("https://api.github.com/user/installations?page=3".to_string())
4511        );
4512        assert!(parse_link_next("<https://x/>; rel=\"last\"").is_none());
4513        assert!(parse_link_next("nonsense").is_none());
4514    }
4515
4516    /// A comma is legal inside a URL and GitHub sends such URLs routinely — a
4517    /// runner query carries `labels=self-hosted,windows`. Splitting the header
4518    /// on `,` before recognising `<...>` tore that URL in half, found no
4519    /// relation, and stopped paginating at page 1 while reporting success. That
4520    /// is precisely what `04-subsystem-contracts.md` forbids ("the dashboard
4521    /// must not treat a first page as a complete inventory"), in the one shared
4522    /// reader `c3`'s inventory also goes through.
4523    #[test]
4524    fn a_next_url_containing_a_comma_still_paginates() {
4525        let header = "<https://api.github.com/repos/o/r/actions/runners\
4526                      ?labels=self-hosted,windows&page=2>; rel=\"next\"";
4527        assert_eq!(
4528            parse_link_next(header).map(|u| u.to_string()),
4529            Some(
4530                "https://api.github.com/repos/o/r/actions/runners\
4531                 ?labels=self-hosted,windows&page=2"
4532                    .to_string()
4533            ),
4534            "a comma inside the URL must not end the link-value"
4535        );
4536
4537        // The same URL as the second link-value, so the scan has to walk past a
4538        // comma-bearing target to reach the relation it wants.
4539        let header = "<https://api.github.com/x?a=1,2&page=1>; rel=\"prev\", \
4540                      <https://api.github.com/x?a=1,2&page=3>; rel=\"next\"";
4541        assert_eq!(
4542            parse_link_next(header).map(|u| u.to_string()),
4543            Some("https://api.github.com/x?a=1,2&page=3".to_string())
4544        );
4545    }
4546
4547    /// `rel="next"` is not always the first link-value, and both quoted and
4548    /// unquoted forms are legal.
4549    #[test]
4550    fn the_next_relation_is_found_wherever_it_sits_in_the_header() {
4551        let not_first = "<https://api.github.com/u?page=1>; rel=\"first\", \
4552                         <https://api.github.com/u?page=9>; rel=\"last\", \
4553                         <https://api.github.com/u?page=4>; rel=\"next\"";
4554        assert_eq!(
4555            parse_link_next(not_first).map(|u| u.to_string()),
4556            Some("https://api.github.com/u?page=4".to_string())
4557        );
4558
4559        let unquoted = "<https://api.github.com/u?page=1>; rel=prev, \
4560                        <https://api.github.com/u?page=3>; rel=next";
4561        assert_eq!(
4562            parse_link_next(unquoted).map(|u| u.to_string()),
4563            Some("https://api.github.com/u?page=3".to_string())
4564        );
4565
4566        assert!(
4567            parse_link_next("<https://api.github.com/u?page=2; rel=\"next\"").is_none(),
4568            "an unterminated target is not a link-value"
4569        );
4570    }
4571
4572    /// The free cross-check that would have caught the comma bug on its own.
4573    #[test]
4574    fn a_short_collection_is_measured_against_the_count_github_reported() {
4575        assert_eq!(under_collected(1, Some(2)), Some(2), "page 2 was dropped");
4576        assert_eq!(under_collected(2, Some(2)), None, "complete");
4577        assert_eq!(
4578            under_collected(3, Some(2)),
4579            None,
4580            "a collection that grew between pages is not an under-collection"
4581        );
4582        assert_eq!(under_collected(0, None), None, "no count, no claim");
4583    }
4584
4585    // -- redaction ----------------------------------------------------------
4586
4587    #[test]
4588    fn no_type_in_this_crate_renders_a_secret_through_debug() {
4589        let token = UserAccessToken::new(SecretString::from(FIXTURE_TOKEN));
4590        let rendered = format!("{token:?}");
4591        assert!(!rendered.contains(FIXTURE_TOKEN), "{rendered}");
4592        assert!(rendered.contains("[REDACTED]"));
4593        assert!(
4594            rendered.contains("ghu_"),
4595            "the family prefix is diagnostic and is not the secret"
4596        );
4597
4598        let request = ApiRequest::post_json("/x", &json!({"encoded_jit_config": "SECRETBLOB"}))
4599            .expect("serializes");
4600        let rendered = format!("{request:?}");
4601        assert!(!rendered.contains("SECRETBLOB"), "{rendered}");
4602
4603        let response = ApiResponse {
4604            status: StatusCode::OK,
4605            headers: HeaderMap::new(),
4606            body: b"{\"encoded_jit_config\":\"SECRETBLOB\"}".to_vec(),
4607        };
4608        let rendered = format!("{response:?}");
4609        assert!(!rendered.contains("SECRETBLOB"), "{rendered}");
4610    }
4611
4612    // The Definition of Done's log scan is `tests/no_secret_reaches_the_logs.rs`
4613    // and not a unit test here. See the note at the end of `mod testing` for the
4614    // `tracing` callsite-cache reason it cannot be one.
4615
4616    // -- the crate-shape scans ----------------------------------------------
4617    //
4618    // The three gates below share these helpers on purpose. The previous round
4619    // defined `normalise` twice — once in the scan and once in the meta-test
4620    // that checks it — which left the meta-test structurally unable to notice a
4621    // change to the real one. One definition, used by both, is the only shape
4622    // in which a meta-test proves anything.
4623
4624    /// Spelled in halves so that this file's own source does not trip the scan
4625    /// it runs: normalising `concat!("refresh", "token")` leaves the
4626    /// quote-comma-quote between the halves, so no needle ever appears whole.
4627    // The renewal guard that used to live here is gone, and its absence is the
4628    // point. It forbade this crate from naming a refresh token at all, on the
4629    // reasoning that the published App opts out of user-token expiration "so
4630    // GitHub issues nothing to renew". That reasoning rested on a second claim
4631    // -- that renewing needs a confidential client credential -- which GitHub's
4632    // own documentation contradicts for the device flow, and which was then
4633    // disproved against live GitHub: a refresh exchange with `client_id` alone
4634    // answers `200`.
4635    //
4636    // What the guard below still forbids is the part that was always true and
4637    // is the reason renewal is safe here: no confidential credential in this
4638    // crate. Renewal was added *without* one, so the remaining half of this
4639    // scan is now evidence for the design rather than against it.
4640    const CONFIDENTIAL: &[&str] = &[concat!("client", "secret"), concat!("app", "secret")];
4641
4642    const MANIFEST: (&str, &str) = ("Cargo.toml", include_str!("../Cargo.toml"));
4643
4644    /// Every `.rs` file at or below `src/`, named by its `/`-joined path
4645    /// relative to `src/` — so a nested module is `("rest/runners.rs",
4646    /// include_str!("rest/runners.rs"))`, not just its file name.
4647    ///
4648    /// This list used to *be* the claim "every source file in the crate", and a
4649    /// hard-coded list is not that claim — it is a snapshot of it. `c3` and `c4`
4650    /// are the tasks that will add files to this directory, so the list was
4651    /// guaranteed to go stale on exactly the work that most needed scanning: a
4652    /// new `pagination.rs` holding a confidential credential passed silently.
4653    /// [`the_confidential_credential_scan_covers_every_source_file`] pins this
4654    /// by walking the directory tree, so adding a file — at the top level or in
4655    /// a subdirectory — and not adding it here fails.
4656    const CRATE_SOURCES: &[(&str, &str)] = &[
4657        ("demand.rs", include_str!("demand.rs")),
4658        ("device_flow.rs", include_str!("device_flow.rs")),
4659        ("jit.rs", include_str!("jit.rs")),
4660        ("lib.rs", include_str!("lib.rs")),
4661        ("rest.rs", include_str!("rest.rs")),
4662    ];
4663
4664    /// The two source files `c2` owns, plus the manifest. The renewal half of
4665    /// the scan stays inside this boundary; see the scan's own documentation.
4666    const SOURCES_OWNED_BY_C2: &[(&str, &str)] = &[
4667        ("device_flow.rs", include_str!("device_flow.rs")),
4668        ("lib.rs", include_str!("lib.rs")),
4669        MANIFEST,
4670    ];
4671
4672    /// Lower-cased with `_` removed, so that one needle catches the snake,
4673    /// camel, Pascal and screaming-snake spellings of an identifier at once.
4674    /// (Those four spellings cannot be written out here: they are exactly what
4675    /// the gate forbids, which is the constraint on documentation this scan
4676    /// imposes and defends below.)
4677    ///
4678    /// # Why `-` is *not* stripped from Rust source
4679    ///
4680    /// It used to be, and that rejected ordinary English. `c3`'s own file opens
4681    /// with a line stating that the gateway holds no such credential, written
4682    /// with the compound adjective English requires — and stripping `-` turned
4683    /// that sentence into the needle, so the gate accused `c3` of naming a
4684    /// confidential credential in the very line that says it holds none. A
4685    /// compound adjective is not an evasion; it is how the language works, and
4686    /// this brief, this crate's documentation and that line all use one.
4687    ///
4688    /// Nothing is lost, because **a Rust identifier cannot contain `-`**.
4689    /// Stripping it never bought identifier coverage: every casing an identifier
4690    /// can actually take is `_`-separated or unseparated, and all of those still
4691    /// collapse onto the needle. What it bought was coverage of a *kebab-case
4692    /// string literal*, and the residual gap is stated plainly rather than
4693    /// papered over: a `.rs` file that wrote this credential's name as a
4694    /// hyphenated string would not be caught here. That gap is narrow on
4695    /// purpose — OAuth 2.0 and GitHub both spell the field `_`-separated, which
4696    /// this catches — and it is the price of a gate that ordinary prose can
4697    /// coexist with. A gate that fires on correct English is not a stricter
4698    /// gate; it is a gate that gets deleted.
4699    ///
4700    /// The alternatives were weighed. Requiring identifier context needs a Rust
4701    /// lexer to tell `a client-secret-free design` from a TOML key, and gets the
4702    /// wrong answer for both string literals and comments. Excluding comment
4703    /// text needs the same lexer to avoid mangling `//` inside a string, and
4704    /// would stop the gate catching a `TODO` comment proposing to read the
4705    /// credential from the environment — which is precisely the drift worth
4706    /// catching early, while it is still a comment. Stripping one character
4707    /// fewer needs neither, which is why it wins.
4708    ///
4709    /// A space is not stripped either, and for the same reason: it is what lets
4710    /// this crate's prose discuss a "client secret" as two words.
4711    fn normalise_source(source: &str) -> String {
4712        source.to_ascii_lowercase().replace('_', "")
4713    }
4714
4715    /// The manifest keeps `-` stripped: TOML keys and crate names are kebab-case
4716    /// by convention, so `-` there is a word separator rather than a hyphen, and
4717    /// a manifest carries no hyphenated English for it to break.
4718    fn normalise_manifest(manifest: &str) -> String {
4719        manifest.to_ascii_lowercase().replace(['_', '-'], "")
4720    }
4721
4722    /// Which normaliser a scanned file gets. The manifest is the only file whose
4723    /// `-` is a separator rather than punctuation.
4724    fn normalise(name: &str, contents: &str) -> String {
4725        if name == MANIFEST.0 {
4726            normalise_manifest(contents)
4727        } else {
4728            normalise_source(contents)
4729        }
4730    }
4731
4732    /// The part of a source file that is not test code.
4733    ///
4734    /// The boundary is the first line that is **exactly** `#[cfg(test)]`, and
4735    /// the word "exactly" is the fix. Splitting on that literal wherever it
4736    /// appeared also split on it in *prose*, and `lib.rs` has carried such a
4737    /// mention since the `testing` module was documented — so the scan below
4738    /// already stopped nine lines early, today, with nothing to say so. A file
4739    /// whose module documentation happened to mention an inline test module
4740    /// would have had its scanned region truncated to a few dozen lines, after
4741    /// which a real `std::fs::write` in non-test code passed silently. That is
4742    /// the same class of defect as the log scan that captured only its own
4743    /// events and the credential scan that claimed a scope it did not have: a
4744    /// gate whose description outran what it did.
4745    fn non_test_prefix(source: &str) -> &str {
4746        let mut offset = 0;
4747        for line in source.split_inclusive('\n') {
4748            if line.trim() == "#[cfg(test)]" {
4749                return &source[..offset];
4750            }
4751            offset += line.len();
4752        }
4753        source
4754    }
4755
4756    /// The Definition of Done's second item, made checkable rather than
4757    /// reviewed: "no renewal token code path exists, and no client secret
4758    /// appears anywhere in the crate **or its configuration**".
4759    ///
4760    /// # Normalised, because a literal scan is evaded by naming
4761    ///
4762    /// This used to be a case-sensitive `contains` over two snake-case
4763    /// spellings, which is a gate that any ordinary Rust or JSON identifier
4764    /// walks straight through: the camel-cased, Pascal-cased and
4765    /// screaming-snake spellings of the very same two identifiers were all
4766    /// invisible to it. None of those is exotic — several are what the
4767    /// surrounding ecosystem actually calls these fields — so evading this gate
4768    /// never had to be deliberate. See [`normalise_source`] for what is
4769    /// collapsed, what is deliberately not, and why.
4770    ///
4771    /// The consequence is that this crate's *prose* may not write those
4772    /// identifiers either, in any casing: it says "renewal token" and "client
4773    /// secret" as separate words, which normalisation preserves and the scan
4774    /// therefore ignores. That is a real constraint on the documentation, and it
4775    /// is the right way round — a gate loosened until the comments compile is
4776    /// not a gate. It is a constraint on *identifier spellings*, though, and
4777    /// never on English: hyphenating a compound adjective is not writing an
4778    /// identifier, and a gate that could not tell those apart is what this round
4779    /// fixed.
4780    ///
4781    /// # Two different scopes, for two different reasons
4782    ///
4783    /// The **renewal** half stays scoped to the two files `c2` owns plus the
4784    /// manifest. A renewal path in `c3`'s or `c4`'s file would be their finding;
4785    /// failing here on their work would be this task reaching across an
4786    /// ownership boundary.
4787    ///
4788    /// The **client secret** half covers every source file in the crate. That is
4789    /// not a boundary crossing but the opposite: a public client cannot hold a
4790    /// client secret at all (D3, `07-security.md`), so one appearing *anywhere*
4791    /// in this crate is a product defect rather than a matter of whose file it
4792    /// is, and `c2` is the designated owner of that clause. "Every source file"
4793    /// is a claim about the directory, so it is checked against the directory —
4794    /// see [`the_confidential_credential_scan_covers_every_source_file`].
4795    #[test]
4796    fn no_confidential_credential_in_this_crate() {
4797        for &(name, source) in CRATE_SOURCES.iter().chain(std::iter::once(&MANIFEST)) {
4798            let haystack = normalise(name, source);
4799            for forbidden in CONFIDENTIAL {
4800                assert!(
4801                    !haystack.contains(forbidden),
4802                    "{name} names {forbidden:?} in some spelling: a public client cannot \
4803                     secure a confidential credential, and this design never tries to (D3)"
4804                );
4805            }
4806        }
4807    }
4808
4809    /// "Every source file in the crate" is a claim about a directory, and the
4810    /// scan above states it as a hard-coded list. A list is a snapshot: the
4811    /// moment `c3` or `c4` adds a file to `src/`, the claim is false and nothing
4812    /// says so. A `src/pagination.rs` holding a confidential credential passed
4813    /// the gate that exists to catch exactly that.
4814    ///
4815    /// Reading the directory here is what turns the claim back into a claim. It
4816    /// cannot be done in the scan itself — `include_str!` needs a literal path
4817    /// at compile time — so the list stays, and this pins it.
4818    ///
4819    /// # Why it walks the tree instead of listing one directory
4820    ///
4821    /// It used to call `read_dir("src")` once and keep the entries ending in
4822    /// `.rs`. That reads like a directory scan and is not one: a subdirectory
4823    /// module — `src/rest/mod.rs`, `src/rest/runners.rs` — arrives as the single
4824    /// entry `rest`, which does not end in `.rs`, so the filter dropped it and
4825    /// took the files underneath with it. The pin went on passing while those
4826    /// files were scanned by nothing at all.
4827    ///
4828    /// That defeated the pin in exactly the case it was written for. `c3` is the
4829    /// REST inventory gateway, a module directory is the ordinary Rust shape for
4830    /// it, and the failure is silent on both sides: the credential scan does not
4831    /// read the file, and the test whose whole job is to notice that reports
4832    /// success.
4833    ///
4834    /// Recursing is the fix, rather than asserting that `src/` holds no
4835    /// subdirectories. The claim being pinned is about *files*, not about
4836    /// layout; banning the directory would fail `c3` for choosing a normal
4837    /// module shape, and a gate that fails correct work is a gate the next round
4838    /// loosens to get its own work compiling — which is how the normalisation
4839    /// half of this same scan was weakened once already.
4840    ///
4841    /// Names are `/`-joined paths relative to `src/`, which is what
4842    /// `include_str!` takes on every platform, so a nested file is listed as
4843    /// `("rest/runners.rs", include_str!("rest/runners.rs"))` and the two sides
4844    /// compare directly.
4845    #[test]
4846    fn the_confidential_credential_scan_covers_every_source_file() {
4847        // Every `.rs` file at or below `dir`, named by its `/`-joined path
4848        // relative to `src/`.
4849        //
4850        // `file_type()` is deliberately not followed through symlinks: a link
4851        // cannot walk this into a cycle, and a symlinked `.rs` file still lands
4852        // in the list through the extension test. A directory is recursed into
4853        // before the extension is considered, so a directory named `foo.rs`
4854        // is walked rather than mistaken for a file.
4855        fn collect(dir: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
4856            for entry in std::fs::read_dir(dir).expect("the source directory is readable") {
4857                let entry = entry.expect("a readable directory entry");
4858                let name = entry.file_name().to_string_lossy().into_owned();
4859                let relative = if prefix.is_empty() {
4860                    name.clone()
4861                } else {
4862                    format!("{prefix}/{name}")
4863                };
4864                if entry.file_type().expect("a readable entry type").is_dir() {
4865                    collect(&entry.path(), &relative, found);
4866                } else if name.ends_with(".rs") {
4867                    found.push(relative);
4868                }
4869            }
4870        }
4871
4872        let mut on_disk = Vec::new();
4873        collect(
4874            std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
4875            "",
4876            &mut on_disk,
4877        );
4878        on_disk.sort();
4879
4880        // Sorted, rather than taken in declaration order. Comparing a sorted
4881        // `on_disk` against an unsorted `scanned` made this assertion depend on
4882        // `CRATE_SOURCES` happening to be declared alphabetically. It is — but
4883        // nothing said so, and the failure that would follow from reordering the
4884        // list is a diff of two lists holding the same names, which reads as a
4885        // coverage gap rather than as the ordering nit it would actually be.
4886        let mut scanned: Vec<String> = CRATE_SOURCES
4887            .iter()
4888            .map(|(name, _)| (*name).to_string())
4889            .collect();
4890        scanned.sort();
4891
4892        assert_eq!(
4893            scanned, on_disk,
4894            "`src/` and the scanned list have diverged. Add the new file to `CRATE_SOURCES` \
4895             with an `include_str!`, naming it by its `/`-joined path relative to `src/`; \
4896             leaving it out means the confidential-credential scan silently stops covering \
4897             `every source file in the crate`, which is the claim it makes."
4898        );
4899    }
4900
4901    /// The scan above, shown to actually catch the spellings it claims to — and
4902    /// to leave alone the ones it claims to leave alone.
4903    ///
4904    /// Without this, "the gate is case-insensitive now" is a comment rather than
4905    /// a fact, and the finding that produced it was precisely a gate whose
4906    /// description outran what it did. It calls [`normalise`], the same function
4907    /// the scan calls, because a meta-test with its own private copy of the
4908    /// thing under test cannot detect a change to it.
4909    #[test]
4910    fn the_confidential_credential_scan_is_not_evaded_by_naming() {
4911        // Assembled at run time rather than written out, for the same reason the
4912        // needles are spelled in halves: a test that contained these spellings
4913        // literally would fail the scan it is checking.
4914        let source_evasions = [
4915            format!("let {}Token = fetch()", "refresh"),
4916            format!("struct {}Token;", "Refresh"),
4917            format!("{}_TOKEN", "REFRESH"),
4918            format!("{}Secret", "client"),
4919            format!("{}_SECRET", "CLIENT"),
4920            format!("{}_secret", "app"),
4921        ];
4922        for evasion in &source_evasions {
4923            let normalised = normalise("lib.rs", evasion);
4924            assert!(
4925                normalised.contains(concat!("refresh", "token"))
4926                    || normalised.contains(concat!("client", "secret"))
4927                    || normalised.contains(concat!("app", "secret")),
4928                "{evasion:?} would walk straight through the scan"
4929            );
4930        }
4931
4932        // The manifest is where kebab-case is a word separator rather than a
4933        // hyphen, so that is where it is still collapsed.
4934        let manifest_evasion = format!("{}-secret = \"...\"", "client");
4935        assert!(
4936            normalise(MANIFEST.0, &manifest_evasion).contains(concat!("client", "secret")),
4937            "a kebab-case TOML key is an identifier, and the manifest normaliser must \
4938             still collapse it"
4939        );
4940
4941        // And the prose the crate legitimately writes must still pass, or the
4942        // gate would be unusable and would be weakened again to make it usable.
4943        for allowed in [
4944            "a public client cannot hold a client secret",
4945            "the published App issues no renewal token",
4946            // The line that fails the old normalisation, quoted from `c3`'s own
4947            // file. It says the *opposite* of what the gate accused it of.
4948            "//! This gateway is deliberately client-secret-free, as D3 requires.",
4949            // The same shape, for the renewal half.
4950            "a refresh-free credential model",
4951        ] {
4952            let normalised = normalise("lib.rs", allowed);
4953            assert!(
4954                !normalised.contains(concat!("client", "secret"))
4955                    && !normalised.contains(concat!("refresh", "token")),
4956                "{allowed:?} is English, not an identifier, and must not trip the scan"
4957            );
4958        }
4959    }
4960
4961    /// The storage boundary, made checkable the same way. `c2` returns the token
4962    /// and never persists it; the machine-scoped store is `d2` and the wiring is
4963    /// `f1`. A dependency on `runner-manager-platform`, or a filesystem write,
4964    /// would silently move that boundary.
4965    #[test]
4966    fn this_crate_persists_nothing_and_does_not_depend_on_the_platform_crate() {
4967        assert!(
4968            !MANIFEST.1.contains("runner-manager-platform"),
4969            "the gateway must be testable with no platform dependency at all"
4970        );
4971
4972        for &(name, source) in SOURCES_OWNED_BY_C2 {
4973            if name == MANIFEST.0 {
4974                continue;
4975            }
4976            // Everything below `#[cfg(test)]` is test code; the boundary is about
4977            // non-test code, and the tests above legitimately read this file.
4978            let non_test = non_test_prefix(source);
4979            // `OpenOptions`, `File::options` and `std::io::Write` are on this
4980            // list because the original four named only the *obvious* ways to
4981            // write a file. A store built with `OpenOptions::new().create(true)`
4982            // would have moved the persistence boundary silently, which is the
4983            // one thing this scan exists to prevent.
4984            for forbidden in [
4985                "std::fs",
4986                "fs::write",
4987                "File::create",
4988                "File::options",
4989                "OpenOptions",
4990                "std::io::Write",
4991                "tokio::fs",
4992            ] {
4993                assert!(
4994                    !non_test.contains(forbidden),
4995                    "{name} performs a filesystem operation ({forbidden:?}) outside its tests"
4996                );
4997            }
4998        }
4999    }
5000
5001    /// The scan above, shown to be looking at what it says it is looking at.
5002    ///
5003    /// `split("#[cfg(test)]")` matched that literal **anywhere**, prose
5004    /// included. One ordinary sentence in a module's documentation truncated the
5005    /// scanned region to whatever preceded it, and every filesystem call after
5006    /// that point became invisible — with the scan still reporting `ok`. This is
5007    /// the third gate in this crate found describing more than it did, so it
5008    /// gets the same treatment as the other two: a synthetic file where the
5009    /// difference is decisive, and an assertion about the real ones.
5010    #[test]
5011    fn the_non_test_boundary_is_a_line_and_not_a_mention() {
5012        // A file shaped like this crate's own: prose that names the attribute,
5013        // then real non-test code, then the actual module.
5014        let file = "//! Test helpers live in an inline #[cfg(test)] module near the bottom.\n\
5015                    \n\
5016                    fn persist() { std::fs::write(\"x\", b\"y\").unwrap(); }\n\
5017                    \n\
5018                    #[cfg(test)]\n\
5019                    mod tests {\n\
5020                        fn helper() { std::fs::write(\"ok-in-tests\", b\"\").unwrap(); }\n\
5021                    }\n";
5022
5023        let non_test = non_test_prefix(file);
5024        assert!(
5025            non_test.contains("fn persist"),
5026            "a prose mention of the attribute truncated the scanned region, and every \
5027             filesystem call below it stopped being scanned — silently:\n{non_test}"
5028        );
5029        assert!(
5030            !non_test.contains("ok-in-tests"),
5031            "the boundary must still exclude the real test module:\n{non_test}"
5032        );
5033
5034        // And on the real files, whose module documentation contains such a
5035        // mention today. `lib.rs` has carried one since `mod testing` was
5036        // written, so this crate was shipping the truncated scan.
5037        for &(name, source) in SOURCES_OWNED_BY_C2 {
5038            if name == MANIFEST.0 {
5039                continue;
5040            }
5041            let expected = source
5042                .lines()
5043                .position(|line| line.trim() == "#[cfg(test)]")
5044                .expect("each source file has an inline test module");
5045            let scanned = non_test_prefix(source).lines().count();
5046            assert_eq!(
5047                scanned, expected,
5048                "{name}: the scanned region ends at line {scanned} but the test module starts \
5049                 at line {expected}. The gap is code that claims to be scanned and is not."
5050            );
5051        }
5052    }
5053
5054    #[derive(Debug)]
5055    struct SpyRenewal {
5056        invocations: std::sync::atomic::AtomicUsize,
5057    }
5058    #[async_trait::async_trait]
5059    impl CredentialRenewal for SpyRenewal {
5060        async fn renew(
5061            &self,
5062            _refresh_token: &SecretString,
5063        ) -> Result<UserAccessToken, RenewalError> {
5064            self.invocations
5065                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5066            Ok(UserAccessToken::new(SecretString::from("ghu_renewed")))
5067        }
5068    }
5069
5070    #[tokio::test]
5071    async fn reload_happens_before_renew_to_prevent_cross_process_races() {
5072        let server = MockServer::start().await;
5073        Mock::given(method("GET"))
5074            .and(path("/repos/acme/app"))
5075            .and(header("authorization", "Bearer ghu_initial"))
5076            .respond_with(ResponseTemplate::new(401))
5077            .expect(1)
5078            .mount(&server)
5079            .await;
5080        Mock::given(method("GET"))
5081            .and(path("/repos/acme/app"))
5082            .and(header("authorization", "Bearer ghu_reloaded"))
5083            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
5084            .expect(1)
5085            .mount(&server)
5086            .await;
5087
5088        let renewal = Arc::new(SpyRenewal {
5089            invocations: std::sync::atomic::AtomicUsize::new(0),
5090        });
5091
5092        let document = serde_json::json!({
5093            "access_token": "ghu_initial",
5094            "refresh_token": "ghr_initial",
5095        });
5096        let initial_token =
5097            UserAccessToken::from_stored_document(&SecretString::from(document.to_string()));
5098
5099        let client = AuthenticatedClient::new(
5100            Endpoints::for_test_server(&server.uri()).unwrap(),
5101            initial_token,
5102            Arc::new(TestClock::default()),
5103        )
5104        .unwrap()
5105        .with_credential_source(Arc::new(StoreHolding(Some("ghu_reloaded"))))
5106        .with_renewal(renewal.clone());
5107
5108        let response = client
5109            .get_json::<serde_json::Value>("/repos/acme/app")
5110            .await
5111            .expect("the reloaded token should succeed");
5112        assert_eq!(response["id"], 1);
5113        assert_eq!(
5114            renewal
5115                .invocations
5116                .load(std::sync::atomic::Ordering::SeqCst),
5117            0,
5118            "renew should not be called because reload succeeded"
5119        );
5120    }
5121
5122    #[tokio::test]
5123    async fn proactive_renewal_waits_until_the_access_token_is_near_expiry() {
5124        let renewal = Arc::new(SpyRenewal {
5125            invocations: std::sync::atomic::AtomicUsize::new(0),
5126        });
5127        let document = serde_json::json!({
5128            "access_token": "ghu_initial",
5129            "refresh_token": "ghr_initial",
5130            "access_expires_at": "2026-08-21T02:00:00Z",
5131            "refresh_expires_at": "2027-02-21T00:00:00Z"
5132        });
5133        let clock = Arc::new(TestClock::default());
5134        let client = AuthenticatedClient::new(
5135            Endpoints::production(),
5136            UserAccessToken::from_stored_document(&SecretString::from(document.to_string())),
5137            clock.clone(),
5138        )
5139        .unwrap()
5140        .with_renewal(renewal.clone());
5141
5142        assert!(
5143            !client
5144                .renew_if_expiring_within(Duration::from_secs(30 * 60))
5145                .await
5146        );
5147        assert_eq!(
5148            renewal
5149                .invocations
5150                .load(std::sync::atomic::Ordering::SeqCst),
5151            0
5152        );
5153
5154        clock.advance_secs(91 * 60);
5155        assert!(
5156            client
5157                .renew_if_expiring_within(Duration::from_secs(30 * 60))
5158                .await
5159        );
5160        assert_eq!(
5161            renewal
5162                .invocations
5163                .load(std::sync::atomic::Ordering::SeqCst),
5164            1
5165        );
5166    }
5167
5168    #[tokio::test]
5169    async fn proactive_renewal_repairs_a_pair_with_no_expiry_metadata() {
5170        let renewal = Arc::new(SpyRenewal {
5171            invocations: std::sync::atomic::AtomicUsize::new(0),
5172        });
5173        let document = serde_json::json!({
5174            "access_token": "ghu_initial",
5175            "refresh_token": "ghr_initial"
5176        });
5177        let client = AuthenticatedClient::new(
5178            Endpoints::production(),
5179            UserAccessToken::from_stored_document(&SecretString::from(document.to_string())),
5180            Arc::new(TestClock::default()),
5181        )
5182        .unwrap()
5183        .with_renewal(renewal.clone());
5184
5185        assert!(
5186            client
5187                .renew_if_expiring_within(Duration::from_secs(30 * 60))
5188                .await
5189        );
5190        assert_eq!(
5191            renewal
5192                .invocations
5193                .load(std::sync::atomic::Ordering::SeqCst),
5194            1
5195        );
5196    }
5197}