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)]
2254pub enum InstallationAccount {
2255 User(String),
2256 Organization(Org),
2257 /// An enterprise account.
2258 ///
2259 /// It is its own variant rather than a [`InstallationAccount::User`]
2260 /// because it is not one, and `auth status` says out loud whose account
2261 /// each installation sits on. Everything GitHub reports without
2262 /// `type: "Organization"` used to fall into `User`, so an enterprise was
2263 /// labelled a user — a wrong statement about the operator's own account, on
2264 /// the one screen that exists to tell them what their credential reaches.
2265 ///
2266 /// It contributes nothing to [`ReachableTargets::organizations`], and that
2267 /// is correct rather than a second bug: an enterprise is not an
2268 /// organization, and `GET /orgs/{org}/actions/runners` does not accept one.
2269 /// The distinction is only visible now because the label is.
2270 Enterprise(String),
2271}
2272
2273impl InstallationAccount {
2274 #[must_use]
2275 pub fn login(&self) -> &str {
2276 match self {
2277 Self::User(login) | Self::Enterprise(login) => login,
2278 Self::Organization(org) => org.as_str(),
2279 }
2280 }
2281
2282 /// The organization, when the account is one. An organization account is a
2283 /// reachable *target* in its own right (D18): a policy may scale for the
2284 /// whole organization.
2285 #[must_use]
2286 pub fn organization(&self) -> Option<&Org> {
2287 match self {
2288 Self::Organization(org) => Some(org),
2289 Self::User(_) | Self::Enterprise(_) => None,
2290 }
2291 }
2292
2293 /// What to call this account in `auth status`. `f1` renders it; nothing in
2294 /// this crate branches on it.
2295 #[must_use]
2296 pub fn kind(&self) -> &'static str {
2297 match self {
2298 Self::User(_) => "user",
2299 Self::Organization(_) => "organization",
2300 Self::Enterprise(_) => "enterprise",
2301 }
2302 }
2303}
2304
2305impl fmt::Display for InstallationAccount {
2306 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2307 f.write_str(self.login())
2308 }
2309}
2310
2311/// One installation of the published App, and what it can actually reach.
2312#[derive(Debug, Clone, PartialEq, Eq)]
2313pub struct Installation {
2314 pub id: u64,
2315 pub account: InstallationAccount,
2316 pub repository_selection: RepositorySelection,
2317 pub repositories: Vec<OwnerRepo>,
2318 /// The permissions GitHub reports for this installation, as
2319 /// `name -> level`. Surfaced verbatim so `auth status` can show a grant the
2320 /// user did not expect rather than assert the published set was applied.
2321 pub permissions: Vec<(String, String)>,
2322}
2323
2324impl Installation {
2325 #[must_use]
2326 pub fn is_over_broad(&self) -> bool {
2327 self.repository_selection.is_over_broad()
2328 }
2329}
2330
2331/// Everything the stored credential can reach.
2332#[derive(Debug, Clone, PartialEq, Eq)]
2333pub struct ReachableTargets {
2334 installations: Vec<Installation>,
2335 skipped: usize,
2336}
2337
2338impl ReachableTargets {
2339 #[must_use]
2340 pub fn installations(&self) -> &[Installation] {
2341 &self.installations
2342 }
2343
2344 /// How many installations GitHub reported that this client could not
2345 /// describe, and therefore left out of everything above.
2346 ///
2347 /// Non-zero means this report is **incomplete**, not merely small: whatever
2348 /// those installations reach is absent from
2349 /// [`ReachableTargets::repositories`] and
2350 /// [`ReachableTargets::organizations`]. `auth status` should say so, because
2351 /// the alternative is an operator reading a short list as a complete one.
2352 #[must_use]
2353 pub fn skipped(&self) -> usize {
2354 self.skipped
2355 }
2356
2357 /// Every repository the credential can reach, sorted and de-duplicated.
2358 #[must_use]
2359 pub fn repositories(&self) -> Vec<OwnerRepo> {
2360 let mut all: Vec<OwnerRepo> = self
2361 .installations
2362 .iter()
2363 .flat_map(|i| i.repositories.iter().cloned())
2364 .collect();
2365 all.sort();
2366 all.dedup();
2367 all
2368 }
2369
2370 /// Every organization the App is installed on, sorted and de-duplicated.
2371 #[must_use]
2372 pub fn organizations(&self) -> Vec<Org> {
2373 let mut all: Vec<Org> = self
2374 .installations
2375 .iter()
2376 .filter_map(|i| i.account.organization().cloned())
2377 .collect();
2378 all.sort();
2379 all.dedup();
2380 all
2381 }
2382
2383 /// The installations that hold `repository_selection: all`.
2384 #[must_use]
2385 pub fn over_broad(&self) -> Vec<&Installation> {
2386 self.installations
2387 .iter()
2388 .filter(|i| i.is_over_broad())
2389 .collect()
2390 }
2391
2392 #[must_use]
2393 pub fn is_empty(&self) -> bool {
2394 self.repositories().is_empty() && self.organizations().is_empty()
2395 }
2396}
2397
2398/// What `auth status` and `auth login` show after a successful sign-in.
2399#[derive(Debug, Clone, PartialEq, Eq)]
2400pub enum InstallationDiscovery {
2401 /// The credential is valid, GitHub reported nothing this client could not
2402 /// describe, and still nothing is reachable: the App is installed nowhere,
2403 /// or on nothing. `03-control-flows.md` flow 1.1 requires the installation
2404 /// URL here, and the URL is the remediation.
2405 NotInstalled { install_url: Url },
2406 /// Nothing is reachable, but at least one installation was **skipped**, so
2407 /// this client cannot tell "not installed" from "installed on something it
2408 /// could not describe".
2409 ///
2410 /// # Why this variant exists at all
2411 ///
2412 /// Skipping an unnameable installation is the right trade — one odd
2413 /// installation must not take down `auth status` for every other one — but
2414 /// it was made silently, and the silence flipped a verdict. An account this
2415 /// client cannot name, on the *only* installation the credential has, used
2416 /// to collapse to [`InstallationDiscovery::NotInstalled`], and `auth status`
2417 /// then handed an already-installed operator the "install the App" URL. That
2418 /// is a wrong remediation on the only authentication path there is,
2419 /// contradicted by nothing louder than a `warn!` in a log the operator is
2420 /// not reading.
2421 ///
2422 /// So the skip stays and the verdict does not flip. There is deliberately no
2423 /// `install_url` here: the whole point is that this client does not know
2424 /// whether installing is the remedy, and offering the URL anyway would put
2425 /// the wrong answer back one field over. `f1` says "1 installation could not
2426 /// be described" and stops there, which is true.
2427 Indeterminate { skipped: usize },
2428 /// The credential reaches at least one repository or organization. It may
2429 /// still be an incomplete picture — see [`ReachableTargets::skipped`].
2430 Installed(ReachableTargets),
2431}
2432
2433impl InstallationDiscovery {
2434 #[must_use]
2435 pub fn targets(&self) -> Option<&ReachableTargets> {
2436 match self {
2437 Self::Installed(t) => Some(t),
2438 Self::NotInstalled { .. } | Self::Indeterminate { .. } => None,
2439 }
2440 }
2441
2442 /// The installation URL, and *only* when installing is actually the
2443 /// remediation. See [`InstallationDiscovery::Indeterminate`].
2444 #[must_use]
2445 pub fn install_url(&self) -> Option<&Url> {
2446 match self {
2447 Self::NotInstalled { install_url } => Some(install_url),
2448 Self::Installed(_) | Self::Indeterminate { .. } => None,
2449 }
2450 }
2451
2452 /// How many installations GitHub reported that this client could not
2453 /// describe, whichever verdict was reached. One call for `f1`, so that
2454 /// "this report is incomplete" does not depend on which variant it landed
2455 /// in.
2456 #[must_use]
2457 pub fn skipped(&self) -> usize {
2458 match self {
2459 Self::NotInstalled { .. } => 0,
2460 Self::Indeterminate { skipped } => *skipped,
2461 Self::Installed(targets) => targets.skipped(),
2462 }
2463 }
2464}
2465
2466#[derive(Debug, Deserialize)]
2467struct InstallationsPage {
2468 /// GitHub reports the size of the whole collection on every page. Decoding
2469 /// it costs nothing and turns silent under-collection into a visible
2470 /// warning — see [`under_collected`].
2471 #[serde(default)]
2472 total_count: Option<u64>,
2473 #[serde(default)]
2474 installations: Vec<RawInstallation>,
2475}
2476
2477#[derive(Debug, Deserialize)]
2478struct RawInstallation {
2479 id: u64,
2480 /// **Nullable.** GitHub's published `installation` schema types `account` as
2481 /// nullable, so a required field here would fail the *whole* decode — and
2482 /// with it all of `discover_installations`, which is all of `auth status` —
2483 /// over one installation whose account this client did not need to name.
2484 #[serde(default)]
2485 account: Option<RawAccount>,
2486 #[serde(default)]
2487 repository_selection: Option<String>,
2488 #[serde(default)]
2489 permissions: std::collections::BTreeMap<String, String>,
2490}
2491
2492/// An installation's account, which is *not* always a simple user.
2493///
2494/// GitHub's schema makes `account` either a simple-user or an enterprise, and an
2495/// enterprise carries `slug` and `name` where a user carries `login`. Requiring
2496/// `login` therefore made an enterprise installation a hard decode failure of
2497/// the entire response. All three are optional here and
2498/// [`RawAccount::display_login`] takes the first usable one.
2499#[derive(Debug, Deserialize)]
2500struct RawAccount {
2501 #[serde(default)]
2502 login: Option<String>,
2503 /// An enterprise account's stable identifier.
2504 #[serde(default)]
2505 slug: Option<String>,
2506 /// An enterprise account's display name, the last resort.
2507 #[serde(default)]
2508 name: Option<String>,
2509 #[serde(rename = "type", default)]
2510 account_type: Option<String>,
2511}
2512
2513impl RawAccount {
2514 fn display_login(&self) -> Option<&str> {
2515 [
2516 self.login.as_deref(),
2517 self.slug.as_deref(),
2518 self.name.as_deref(),
2519 ]
2520 .into_iter()
2521 .flatten()
2522 .find(|value| !value.is_empty())
2523 }
2524
2525 /// An account with no `login` that still names itself is an enterprise:
2526 /// `slug`/`name` is the enterprise shape, and every simple-user and
2527 /// organization account carries `login`.
2528 fn is_enterprise_shaped(&self) -> bool {
2529 self.login.as_deref().is_none_or(str::is_empty)
2530 && (self.slug.as_deref().is_some_and(|s| !s.is_empty())
2531 || self.name.as_deref().is_some_and(|s| !s.is_empty()))
2532 }
2533}
2534
2535#[derive(Debug, Deserialize)]
2536struct RepositoriesPage {
2537 #[serde(default)]
2538 total_count: Option<u64>,
2539 #[serde(default)]
2540 repositories: Vec<RawRepository>,
2541}
2542
2543#[derive(Debug, Deserialize)]
2544struct RawRepository {
2545 full_name: String,
2546}
2547
2548/// How many items a paginated collection said it had, when that is more than
2549/// arrived.
2550///
2551/// This is the cheapest possible check and it is worth more than it looks. The
2552/// `Link`-header parser used to lose the relation whenever a page URL contained
2553/// a comma, which stopped pagination at page 1 — and *nothing* noticed, because
2554/// a short answer and a complete answer are the same shape. Cross-checking the
2555/// count GitHub itself reported turns that class of bug from a wrong answer into
2556/// a logged warning. A collection larger than `total_count` is not reported:
2557/// GitHub can legitimately grow a collection between pages.
2558fn under_collected(collected: usize, total_count: Option<u64>) -> Option<u64> {
2559 let total = total_count?;
2560 (total > collected as u64).then_some(total)
2561}
2562
2563impl AuthenticatedClient {
2564 /// Which repositories and organizations the stored credential can actually
2565 /// reach.
2566 ///
2567 /// Two calls, both paginated: `GET /user/installations`, then
2568 /// `GET /user/installations/{id}/repositories` per installation. The shapes
2569 /// are the ones the D18 spike observed live
2570 /// (`docs/spikes/d18-org-jit-verification.md`, "The permission that
2571 /// authorized it").
2572 ///
2573 /// An installation is reported even when it is broader than the user
2574 /// expected — [`Installation::is_over_broad`] — because `07-security.md`
2575 /// requires that an over-broad installation be *visible* rather than
2576 /// assumed. Nothing here narrows or hides one.
2577 ///
2578 /// # Errors
2579 /// Every variant of [`GithubError`]. A `401` here goes through the same
2580 /// single-flight re-validation as any other request.
2581 pub async fn discover_installations(
2582 &self,
2583 app: &AppRegistration,
2584 ) -> Result<InstallationDiscovery, GithubError> {
2585 let mut installations = Vec::new();
2586 let mut skipped = 0_usize;
2587 for raw in self.all_installations().await? {
2588 // A null or nameless account is skipped rather than fatal. GitHub
2589 // types this field as nullable, and one unnameable installation must
2590 // not take down `auth status` for every other one — but it is also
2591 // not something to swallow quietly, because the repositories behind
2592 // it are then absent from the reported reach. The count is what
2593 // carries that out of here; a `warn!` alone let the skip change the
2594 // verdict with nothing to say so.
2595 let Some(login) = raw.account.as_ref().and_then(RawAccount::display_login) else {
2596 skipped += 1;
2597 tracing::warn!(
2598 installation_id = raw.id,
2599 "skipping an installation GitHub reported with no nameable account; \
2600 anything it reaches is missing from this report"
2601 );
2602 continue;
2603 };
2604 let account_type = raw.account.as_ref().and_then(|a| a.account_type.as_deref());
2605 let account = match account_type {
2606 Some("Organization") => {
2607 InstallationAccount::Organization(Org::new(login).map_err(|_| {
2608 GithubError::Malformed {
2609 what: "an installation account login",
2610 value: login.to_string(),
2611 }
2612 })?)
2613 }
2614 Some("Enterprise") => InstallationAccount::Enterprise(login.to_string()),
2615 // An enterprise is also reported with no `type` at all, carrying
2616 // `slug`/`name` where a user carries `login` — which is the
2617 // shape D18 observed and the shape `display_login` exists for.
2618 // Recognising it by that shape is what stops it being labelled a
2619 // user by default.
2620 _ if raw
2621 .account
2622 .as_ref()
2623 .is_some_and(RawAccount::is_enterprise_shaped) =>
2624 {
2625 InstallationAccount::Enterprise(login.to_string())
2626 }
2627 _ => InstallationAccount::User(login.to_string()),
2628 };
2629 let repository_selection = match raw.repository_selection.as_deref() {
2630 Some("all") => RepositorySelection::All,
2631 _ => RepositorySelection::Selected,
2632 };
2633 installations.push(Installation {
2634 id: raw.id,
2635 account,
2636 repository_selection,
2637 repositories: self.installation_repositories(raw.id).await?,
2638 permissions: raw.permissions.into_iter().collect(),
2639 });
2640 }
2641
2642 let targets = ReachableTargets {
2643 installations,
2644 skipped,
2645 };
2646 if targets.is_empty() {
2647 // "Nothing reachable" and "nothing this client could describe" are
2648 // different answers, and only the first one is fixed by installing
2649 // the App. Reporting them as the same answer is how an
2650 // already-installed operator was handed an install URL.
2651 if skipped > 0 {
2652 tracing::warn!(
2653 skipped,
2654 "every installation GitHub reported was skipped; whether the App is \
2655 installed cannot be determined from this credential"
2656 );
2657 return Ok(InstallationDiscovery::Indeterminate { skipped });
2658 }
2659 let install_url = app.install_url(&self.endpoints);
2660 tracing::info!(
2661 install_url = %install_url,
2662 "the published App is not installed on anything this credential can reach"
2663 );
2664 return Ok(InstallationDiscovery::NotInstalled { install_url });
2665 }
2666 tracing::info!(
2667 repositories = targets.repositories().len(),
2668 organizations = targets.organizations().len(),
2669 over_broad = targets.over_broad().len(),
2670 skipped,
2671 "discovered the targets this credential can reach"
2672 );
2673 Ok(InstallationDiscovery::Installed(targets))
2674 }
2675
2676 async fn all_installations(&self) -> Result<Vec<RawInstallation>, GithubError> {
2677 let mut out = Vec::new();
2678 let mut total_count = None;
2679 let mut next = Some(ApiRequest::get("/user/installations").query("per_page", 100));
2680 let mut pages = 0_usize;
2681 while let Some(request) = next.take() {
2682 let response = self.send(&request).await?;
2683 let page: InstallationsPage = response.json()?;
2684 total_count = page.total_count.or(total_count);
2685 out.extend(page.installations);
2686
2687 pages += 1;
2688 if pages >= MAX_PAGES {
2689 tracing::warn!(
2690 pages,
2691 collected = out.len(),
2692 "stopped following installation pages at the ceiling; a `Link: rel=next` \
2693 that never ends would otherwise loop forever"
2694 );
2695 break;
2696 }
2697 next = response
2698 .next_page()
2699 .map(|url| ApiRequest::get(url.as_str()));
2700 }
2701 if let Some(expected) = under_collected(out.len(), total_count) {
2702 tracing::warn!(
2703 expected,
2704 collected = out.len(),
2705 "GitHub reported more installations than pagination collected; the reported \
2706 reach is incomplete"
2707 );
2708 }
2709 Ok(out)
2710 }
2711
2712 async fn installation_repositories(&self, id: u64) -> Result<Vec<OwnerRepo>, GithubError> {
2713 let mut out = Vec::new();
2714 let mut total_count = None;
2715 let mut next = Some(
2716 ApiRequest::get(format!("/user/installations/{id}/repositories"))
2717 .query("per_page", 100),
2718 );
2719 let mut pages = 0_usize;
2720 while let Some(request) = next.take() {
2721 let response = self.send(&request).await?;
2722 let page: RepositoriesPage = response.json()?;
2723 total_count = page.total_count.or(total_count);
2724 for repo in page.repositories {
2725 out.push(OwnerRepo::parse(&repo.full_name).map_err(|_| {
2726 GithubError::Malformed {
2727 what: "a repository full_name",
2728 value: repo.full_name.clone(),
2729 }
2730 })?);
2731 }
2732
2733 pages += 1;
2734 if pages >= MAX_PAGES {
2735 tracing::warn!(
2736 installation_id = id,
2737 pages,
2738 collected = out.len(),
2739 "stopped following repository pages at the ceiling; a `Link: rel=next` \
2740 that never ends would otherwise loop forever"
2741 );
2742 break;
2743 }
2744 next = response
2745 .next_page()
2746 .map(|url| ApiRequest::get(url.as_str()));
2747 }
2748 if let Some(expected) = under_collected(out.len(), total_count) {
2749 tracing::warn!(
2750 installation_id = id,
2751 expected,
2752 collected = out.len(),
2753 "GitHub reported more repositories than pagination collected; this \
2754 installation's reach is under-reported"
2755 );
2756 }
2757 Ok(out)
2758 }
2759}
2760
2761/// Test support shared by this file and [`device_flow`].
2762///
2763/// It lives inline rather than in `src/testing.rs` on purpose. `a1` laid out
2764/// this crate's five source files — `lib.rs`, `device_flow.rs`, `rest.rs`,
2765/// `demand.rs`, `jit.rs` — and owns every manifest; `c3` and `c4` are working in
2766/// the same directory in parallel, and a new file there is a merge conflict
2767/// waiting to happen for no benefit. An inline `#[cfg(test)]` module is
2768/// reachable as `crate::testing` from every module in the crate and adds nothing
2769/// to a release build.
2770///
2771/// It does not live in `runner-manager-testkit` either, and that one is
2772/// mechanical: `testkit` depends on `runner-manager-github`, so a unit test
2773/// inside this crate that used a `testkit` helper would link a *second* instance
2774/// of this library and the two instances' types would not unify — the same
2775/// hazard `testkit`'s own crate documentation records for `domain`.
2776#[cfg(test)]
2777pub(crate) mod testing {
2778 use super::*;
2779 use serde_json::{Value, json};
2780 use std::sync::{Mutex, atomic::AtomicUsize};
2781 use wiremock::{Request, Respond, ResponseTemplate};
2782
2783 /// Shaped like a real `ghu_` token, and unmistakably not one.
2784 pub const FIXTURE_TOKEN: &str = "ghu_fixtureTOKENnotARealCredential00";
2785 /// Shaped like a real device code, and unmistakably not one.
2786 pub const FIXTURE_DEVICE_CODE: &str = "fixture-device-code-0e37a9c1b4d84f2a";
2787 /// The example user code from RFC 8628.
2788 pub const FIXTURE_USER_CODE: &str = "WDJB-MJHT";
2789
2790 /// A clock the test moves.
2791 ///
2792 /// Deliberately not `runner_manager_testkit::clock::FakeClock`; see this
2793 /// module's documentation for why a `testkit` import is not available here.
2794 #[derive(Debug)]
2795 pub struct TestClock {
2796 now: Mutex<Timestamp>,
2797 }
2798
2799 impl TestClock {
2800 /// # Panics
2801 /// If a previous holder panicked while the lock was held.
2802 pub fn advance_secs(&self, secs: i64) {
2803 let mut now = self.now.lock().expect("TestClock lock poisoned");
2804 *now += chrono::TimeDelta::seconds(secs);
2805 }
2806 }
2807
2808 impl Default for TestClock {
2809 fn default() -> Self {
2810 // 2026-08-21T00:00:00Z, the date this taskflow's decisions were
2811 // locked — the same epoch `testkit`'s clock starts at.
2812 Self {
2813 now: Mutex::new(
2814 chrono::DateTime::from_timestamp(1_787_270_400, 0).expect("a valid instant"),
2815 ),
2816 }
2817 }
2818 }
2819
2820 impl Clock for TestClock {
2821 fn now(&self) -> Timestamp {
2822 *self.now.lock().expect("TestClock lock poisoned")
2823 }
2824 }
2825
2826 /// A sleeper that records what it was asked to wait and returns at once.
2827 ///
2828 /// This is what turns "`slow_down` demonstrably increases the poll interval"
2829 /// into an equality assertion on a `Vec<Duration>`.
2830 #[derive(Debug, Default)]
2831 pub struct RecordingSleeper {
2832 recorded: Mutex<Vec<Duration>>,
2833 }
2834
2835 impl RecordingSleeper {
2836 /// # Panics
2837 /// If a previous holder panicked while the lock was held.
2838 pub fn recorded(&self) -> Vec<Duration> {
2839 self.recorded.lock().expect("sleeper lock poisoned").clone()
2840 }
2841 }
2842
2843 #[async_trait::async_trait]
2844 impl Sleeper for RecordingSleeper {
2845 async fn sleep(&self, duration: Duration) {
2846 self.recorded
2847 .lock()
2848 .expect("sleeper lock poisoned")
2849 .push(duration);
2850 }
2851 }
2852
2853 /// Answers from a fixed script, one entry per call, repeating the last.
2854 pub struct Script {
2855 responses: Vec<ResponseTemplate>,
2856 calls: AtomicUsize,
2857 }
2858
2859 impl Script {
2860 #[must_use]
2861 pub fn new(responses: Vec<ResponseTemplate>) -> Self {
2862 assert!(
2863 !responses.is_empty(),
2864 "a script needs at least one response"
2865 );
2866 Self {
2867 responses,
2868 calls: AtomicUsize::new(0),
2869 }
2870 }
2871 }
2872
2873 impl Respond for Script {
2874 fn respond(&self, _: &Request) -> ResponseTemplate {
2875 let i = self.calls.fetch_add(1, Ordering::SeqCst);
2876 self.responses[i.min(self.responses.len() - 1)].clone()
2877 }
2878 }
2879
2880 /// `POST https://github.com/login/device/code` → `200`, in the shape both
2881 /// spikes observed (`docs/spikes/d17-spike.ps1`).
2882 #[must_use]
2883 pub fn device_code_body(server_uri: &str, interval: u64, expires_in: u64) -> Value {
2884 json!({
2885 "device_code": FIXTURE_DEVICE_CODE,
2886 "user_code": FIXTURE_USER_CODE,
2887 "verification_uri": format!("{server_uri}/login/device"),
2888 "expires_in": expires_in,
2889 "interval": interval
2890 })
2891 }
2892
2893 /// `POST .../login/oauth/access_token` → `200` with an `error` field, which
2894 /// is how GitHub answers every state in the matrix.
2895 #[must_use]
2896 pub fn error_body(code: &str, interval: Option<u64>) -> Value {
2897 let mut body = json!({
2898 "error": code,
2899 "error_description": "see the OAuth 2.0 Device Authorization Grant",
2900 "error_uri": "https://docs.github.com/developers/apps/authorizing-oauth-apps"
2901 });
2902 if let Some(interval) = interval {
2903 body["interval"] = json!(interval);
2904 }
2905 body
2906 }
2907
2908 /// `POST .../login/oauth/access_token` → `200` with an approved token.
2909 #[must_use]
2910 pub fn token_body() -> Value {
2911 json!({ "access_token": FIXTURE_TOKEN, "token_type": "bearer", "scope": "" })
2912 }
2913
2914 /// `GET /user/installations` → `200`. The permission set is the one D18 read
2915 /// back from the live installation.
2916 #[must_use]
2917 pub fn installations_body(entries: &[(u64, &str, &str, &str)]) -> Value {
2918 let installations: Vec<Value> = entries
2919 .iter()
2920 .map(|(id, login, account_type, selection)| {
2921 json!({
2922 "id": id,
2923 "account": { "login": login, "type": account_type },
2924 "repository_selection": selection,
2925 "permissions": {
2926 "actions": "read",
2927 "administration": "write",
2928 "metadata": "read",
2929 "organization_self_hosted_runners": "write"
2930 }
2931 })
2932 })
2933 .collect();
2934 json!({ "total_count": installations.len(), "installations": installations })
2935 }
2936
2937 /// `GET /user/installations/{id}/repositories` → `200`.
2938 #[must_use]
2939 pub fn repositories_body(full_names: &[&str]) -> Value {
2940 let repositories: Vec<Value> = full_names
2941 .iter()
2942 .map(|full_name| json!({ "full_name": full_name }))
2943 .collect();
2944 json!({ "total_count": repositories.len(), "repositories": repositories })
2945 }
2946
2947 // The `tracing` capture subscriber that used to live here now lives in
2948 // `tests/no_secret_reaches_the_logs.rs`, and the move is the point rather
2949 // than tidying. `tracing` caches a callsite's `Interest` process-wide while
2950 // `with_default` installs a subscriber only on the calling *thread*, so a
2951 // scan running alongside the crate's other unit tests captured nothing but
2952 // its own handful of events and passed with a real device-code leak in the
2953 // flow. A scan that is the only test in its process has no concurrent
2954 // thread to be poisoned by, and no `#[cfg(test)]` module here can offer
2955 // that guarantee.
2956 //
2957 // The blinding is a *concurrency* effect and not a permanent
2958 // first-registration one — see that file's header for the measurement that
2959 // separates the two. The distinction matters here because only the
2960 // concurrency reading implies what this comment concludes: that one test
2961 // per process is the fix.
2962}
2963
2964#[cfg(test)]
2965mod tests {
2966
2967 /// Upgrading must not log anybody out, and the App's expiration setting must
2968 /// be safe to turn on -- or back off -- with hosts mid-way through either.
2969 #[test]
2970 fn both_stored_shapes_load_and_a_pair_survives_a_round_trip() {
2971 // What every host stored before renewal existed: a bare token.
2972 let legacy = UserAccessToken::from_stored_document(&SecretString::from("ghu_legacy123"));
2973 assert_eq!(legacy.secret().expose_secret(), "ghu_legacy123");
2974 assert!(
2975 legacy.renewal().is_none(),
2976 "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"
2977 );
2978
2979 // A pair, written and read back.
2980 let pair = UserAccessToken::new(SecretString::from("ghu_new")).with_renewal(
2981 Some(SecretString::from("ghr_new")),
2982 Some(28_800),
2983 Some(15_897_600),
2984 );
2985 let stored = pair.to_stored_document();
2986 let read = UserAccessToken::from_stored_document(&stored);
2987 assert_eq!(read.secret().expose_secret(), "ghu_new");
2988 let renewal = read.renewal().expect("the pair survives the round trip");
2989 assert_eq!(renewal.refresh_token().expose_secret(), "ghr_new");
2990 assert!(renewal.access_expires_at.is_some());
2991 assert!(renewal.refresh_expires_at.is_some());
2992
2993 // A credential with no renewal still writes the document shape, and
2994 // still reads back as having none.
2995 let bare_round_trip = UserAccessToken::from_stored_document(&legacy.to_stored_document());
2996 assert_eq!(bare_round_trip.secret().expose_secret(), "ghu_legacy123");
2997 assert!(bare_round_trip.renewal().is_none());
2998 }
2999
3000 /// The refresh token is the more dangerous half -- it mints access tokens
3001 /// for six months -- so it must not reach a log through `Debug`.
3002 #[test]
3003 fn a_refresh_token_never_appears_in_debug_output() {
3004 let pair = UserAccessToken::new(SecretString::from("ghu_x")).with_renewal(
3005 Some(SecretString::from("ghr_SUPERSECRET")),
3006 Some(1),
3007 Some(2),
3008 );
3009 let rendered = format!("{:?}", pair.renewal().expect("a renewal"));
3010 assert!(!rendered.contains("ghr_SUPERSECRET"), "{rendered}");
3011 assert!(rendered.contains("redacted"), "{rendered}");
3012 }
3013 use super::*;
3014 use crate::testing::{FIXTURE_TOKEN, Script, TestClock, installations_body, repositories_body};
3015 use serde_json::json;
3016 use wiremock::{
3017 Mock, MockServer, ResponseTemplate,
3018 matchers::{header, method, path},
3019 };
3020
3021 fn client(server: &MockServer, clock: Arc<TestClock>) -> AuthenticatedClient {
3022 AuthenticatedClient::new(
3023 Endpoints::for_test_server(&server.uri()).unwrap(),
3024 UserAccessToken::new(SecretString::from(FIXTURE_TOKEN)),
3025 clock,
3026 )
3027 .unwrap()
3028 }
3029
3030 fn app() -> AppRegistration {
3031 AppRegistration::new("Iv23liTESTCLIENTID", "runner-manager").unwrap()
3032 }
3033
3034 // -- picking up a credential somebody else stored -------------------------
3035
3036 /// A [`CredentialSource`] over a fixed answer, which is what a store looks
3037 /// like from here.
3038 #[derive(Debug)]
3039 struct StoreHolding(Option<&'static str>);
3040
3041 impl CredentialSource for StoreHolding {
3042 fn reload(&self) -> Option<UserAccessToken> {
3043 self.0
3044 .map(|token| UserAccessToken::new(SecretString::from(token)))
3045 }
3046 }
3047
3048 /// The 28-hour failure, as a test: a daemon holding a dead bare token, an
3049 /// operator who signs in, and nothing that tells the daemon.
3050 ///
3051 /// Bare on purpose. A pair would renew and never reach the store at all,
3052 /// which is why renewal alone did not cover this.
3053 #[tokio::test]
3054 async fn a_daemon_picks_up_a_sign_in_that_happened_after_it_started() {
3055 let server = MockServer::start().await;
3056 Mock::given(method("GET"))
3057 .and(path("/repos/acme/app"))
3058 .and(header("authorization", "Bearer ghu_dead"))
3059 .respond_with(ResponseTemplate::new(401))
3060 .expect(1)
3061 .mount(&server)
3062 .await;
3063 Mock::given(method("GET"))
3064 .and(path("/repos/acme/app"))
3065 .and(header("authorization", "Bearer ghu_freshly_signed_in"))
3066 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": 1})))
3067 .expect(1)
3068 .mount(&server)
3069 .await;
3070
3071 let client = AuthenticatedClient::new(
3072 Endpoints::for_test_server(&server.uri()).unwrap(),
3073 UserAccessToken::new(SecretString::from("ghu_dead")),
3074 Arc::new(TestClock::default()),
3075 )
3076 .unwrap()
3077 .with_credential_source(Arc::new(StoreHolding(Some("ghu_freshly_signed_in"))));
3078
3079 client
3080 .send(&ApiRequest::get("/repos/acme/app"))
3081 .await
3082 .expect(
3083 "the 401 is retried with what the store holds now, without anybody \n restarting the daemon",
3084 );
3085 }
3086
3087 /// The other half, and the reason for the comparison in `reload_once`: a
3088 /// store that still holds the token that just failed is not news.
3089 ///
3090 /// Without the check, every `401` would answer "something changed, retry"
3091 /// and a genuinely revoked credential would spend two requests per poll
3092 /// forever instead of being reported.
3093 #[tokio::test]
3094 async fn a_store_holding_the_same_dead_token_is_not_worth_a_retry() {
3095 let server = MockServer::start().await;
3096 Mock::given(method("GET"))
3097 .and(path("/repos/acme/app"))
3098 .respond_with(ResponseTemplate::new(401))
3099 .expect(1)
3100 .mount(&server)
3101 .await;
3102 // The re-validation probe that runs once reload declines.
3103 Mock::given(method("GET"))
3104 .and(path("/user/installations"))
3105 .respond_with(ResponseTemplate::new(401))
3106 .expect(1)
3107 .mount(&server)
3108 .await;
3109
3110 let client = AuthenticatedClient::new(
3111 Endpoints::for_test_server(&server.uri()).unwrap(),
3112 UserAccessToken::new(SecretString::from("ghu_revoked")),
3113 Arc::new(TestClock::default()),
3114 )
3115 .unwrap()
3116 .with_credential_source(Arc::new(StoreHolding(Some("ghu_revoked"))));
3117
3118 let failure = client
3119 .send(&ApiRequest::get("/repos/acme/app"))
3120 .await
3121 .expect_err("a revoked credential is still revoked when the store agrees");
3122 assert!(
3123 matches!(failure, GithubError::AuthenticationFailed),
3124 "{failure:?}"
3125 );
3126 }
3127
3128 /// An unreadable store leaves the `401` exactly where it was, rather than
3129 /// turning a rejection into a different kind of error.
3130 #[tokio::test]
3131 async fn an_unreadable_store_changes_nothing_about_the_rejection() {
3132 let server = MockServer::start().await;
3133 Mock::given(method("GET"))
3134 .and(path("/repos/acme/app"))
3135 .respond_with(ResponseTemplate::new(401))
3136 .expect(1)
3137 .mount(&server)
3138 .await;
3139 Mock::given(method("GET"))
3140 .and(path("/user/installations"))
3141 .respond_with(ResponseTemplate::new(401))
3142 .expect(1)
3143 .mount(&server)
3144 .await;
3145
3146 let client = AuthenticatedClient::new(
3147 Endpoints::for_test_server(&server.uri()).unwrap(),
3148 UserAccessToken::new(SecretString::from("ghu_revoked")),
3149 Arc::new(TestClock::default()),
3150 )
3151 .unwrap()
3152 .with_credential_source(Arc::new(StoreHolding(None)));
3153
3154 let failure = client
3155 .send(&ApiRequest::get("/repos/acme/app"))
3156 .await
3157 .expect_err("nothing to pick up means the rejection stands");
3158 assert!(
3159 matches!(failure, GithubError::AuthenticationFailed),
3160 "{failure:?}"
3161 );
3162 }
3163
3164 // -- headers ------------------------------------------------------------
3165
3166 #[tokio::test]
3167 async fn every_request_states_its_api_version_and_accept_header() {
3168 let server = MockServer::start().await;
3169 Mock::given(method("GET"))
3170 .and(path("/user/installations"))
3171 .and(header("x-github-api-version", GITHUB_API_VERSION))
3172 .and(header("accept", GITHUB_ACCEPT))
3173 .and(header("authorization", format!("Bearer {FIXTURE_TOKEN}")))
3174 .and(header("user-agent", USER_AGENT))
3175 .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3176 .expect(1)
3177 .mount(&server)
3178 .await;
3179
3180 let client = client(&server, Arc::new(TestClock::default()));
3181 client
3182 .send(&ApiRequest::get("/user/installations"))
3183 .await
3184 .expect("the mock only matches when all four headers are present");
3185 }
3186
3187 // -- the 401 path -------------------------------------------------------
3188
3189 #[tokio::test]
3190 async fn a_401_revalidates_once_and_retries_once_then_succeeds() {
3191 let server = MockServer::start().await;
3192 Mock::given(method("GET"))
3193 .and(path("/orgs/acme/actions/runners"))
3194 .respond_with(Script::new(vec![
3195 ResponseTemplate::new(401).set_body_json(json!({"message": "Bad credentials"})),
3196 ResponseTemplate::new(200).set_body_json(json!({"total_count": 0})),
3197 ]))
3198 .expect(2)
3199 .mount(&server)
3200 .await;
3201 Mock::given(method("GET"))
3202 .and(path("/user/installations"))
3203 .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3204 .expect(1)
3205 .mount(&server)
3206 .await;
3207
3208 let client = client(&server, Arc::new(TestClock::default()));
3209 let response = client
3210 .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3211 .await
3212 .expect("the retry succeeds");
3213
3214 assert_eq!(response.status(), StatusCode::OK);
3215 assert_eq!(
3216 client.revalidations_performed(),
3217 1,
3218 "one 401 must produce exactly one re-validation"
3219 );
3220 }
3221
3222 #[tokio::test]
3223 async fn a_second_401_after_the_retry_is_terminal_authentication_failure() {
3224 let server = MockServer::start().await;
3225 Mock::given(method("GET"))
3226 .and(path("/orgs/acme/actions/runners"))
3227 .respond_with(ResponseTemplate::new(401))
3228 .expect(2)
3229 .mount(&server)
3230 .await;
3231 Mock::given(method("GET"))
3232 .and(path("/user/installations"))
3233 .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3234 .mount(&server)
3235 .await;
3236
3237 let client = client(&server, Arc::new(TestClock::default()));
3238 let err = client
3239 .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3240 .await
3241 .expect_err("two 401s is terminal");
3242
3243 assert!(matches!(err, GithubError::AuthenticationFailed), "{err:?}");
3244 assert!(err.is_authentication());
3245 assert!(!err.is_lockout());
3246 }
3247
3248 #[tokio::test]
3249 async fn a_rejected_revalidation_fails_without_spending_the_retry() {
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 // Exactly one: a credential GitHub has confirmed dead must not be
3255 // used for a retry.
3256 .expect(1)
3257 .mount(&server)
3258 .await;
3259 Mock::given(method("GET"))
3260 .and(path("/user/installations"))
3261 .respond_with(ResponseTemplate::new(401))
3262 .expect(1)
3263 .mount(&server)
3264 .await;
3265
3266 let client = client(&server, Arc::new(TestClock::default()));
3267 let err = client
3268 .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3269 .await
3270 .expect_err("the credential is dead");
3271 assert!(matches!(err, GithubError::AuthenticationFailed), "{err:?}");
3272 }
3273
3274 /// The Definition of Done's concurrency claim, tested with real concurrent
3275 /// callers on a multi-threaded runtime rather than by reasoning about the
3276 /// mutex.
3277 ///
3278 /// Two things make the assertion deterministic rather than lucky. A barrier
3279 /// releases all eight callers into `send` together, so all eight take their
3280 /// `401` before any of them reaches the gate; and the re-validation endpoint
3281 /// is delayed, so the first caller still holds the gate while the other
3282 /// seven sample the generation counter. Without the delay a caller could
3283 /// legitimately arrive after the first re-validation completed, which is a
3284 /// *new* `401` storm and correctly earns its own attempt.
3285 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
3286 async fn eight_concurrent_401s_produce_one_revalidation_not_eight() {
3287 const CALLERS: usize = 8;
3288
3289 let server = MockServer::start().await;
3290 Mock::given(method("GET"))
3291 .and(path("/orgs/acme/actions/runners"))
3292 .respond_with(ResponseTemplate::new(401))
3293 .mount(&server)
3294 .await;
3295 Mock::given(method("GET"))
3296 .and(path("/user/installations"))
3297 .respond_with(
3298 ResponseTemplate::new(200)
3299 .set_body_json(installations_body(&[]))
3300 .set_delay(Duration::from_millis(250)),
3301 )
3302 .expect(1)
3303 .mount(&server)
3304 .await;
3305
3306 let client = Arc::new(client(&server, Arc::new(TestClock::default())));
3307 let barrier = Arc::new(tokio::sync::Barrier::new(CALLERS));
3308 let mut tasks = Vec::new();
3309 for _ in 0..CALLERS {
3310 let client = Arc::clone(&client);
3311 let barrier = Arc::clone(&barrier);
3312 tasks.push(tokio::spawn(async move {
3313 barrier.wait().await;
3314 client
3315 .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3316 .await
3317 .expect_err("every caller sees a dead endpoint")
3318 }));
3319 }
3320
3321 let mut outcomes = Vec::new();
3322 for task in tasks {
3323 outcomes.push(task.await.expect("no caller panicked"));
3324 }
3325
3326 assert_eq!(outcomes.len(), CALLERS);
3327 for err in &outcomes {
3328 assert!(matches!(err, GithubError::AuthenticationFailed), "{err:?}");
3329 }
3330 assert_eq!(
3331 client.revalidations_performed(),
3332 1,
3333 "{CALLERS} concurrent 401s must produce ONE attempt, not {CALLERS}"
3334 );
3335
3336 // The same claim, measured from the server rather than from our own
3337 // counter: the mock's `.expect(1)` is verified when the server drops.
3338 let seen = server.received_requests().await.expect("recording is on");
3339 let probes = seen
3340 .iter()
3341 .filter(|r| r.url.path() == "/user/installations")
3342 .count();
3343 assert_eq!(probes, 1, "GitHub itself saw exactly one re-validation");
3344 let attempts = seen
3345 .iter()
3346 .filter(|r| r.url.path() == "/orgs/acme/actions/runners")
3347 .count();
3348 assert_eq!(
3349 attempts,
3350 CALLERS * 2,
3351 "each caller still gets its own single retry"
3352 );
3353 }
3354
3355 // -- the 403 path -------------------------------------------------------
3356
3357 #[tokio::test]
3358 async fn a_403_after_401s_is_a_lockout_and_not_an_authentication_failure() {
3359 let server = MockServer::start().await;
3360 Mock::given(method("GET"))
3361 .and(path("/orgs/acme/actions/runners"))
3362 .respond_with(Script::new(vec![
3363 ResponseTemplate::new(401),
3364 ResponseTemplate::new(403).insert_header("retry-after", "42"),
3365 ]))
3366 .mount(&server)
3367 .await;
3368 Mock::given(method("GET"))
3369 .and(path("/user/installations"))
3370 .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3371 .mount(&server)
3372 .await;
3373
3374 let client = client(&server, Arc::new(TestClock::default()));
3375 let err = client
3376 .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3377 .await
3378 .expect_err("403 after a 401");
3379
3380 match err {
3381 GithubError::AuthenticationLockout { retry_after } => {
3382 assert_eq!(retry_after, Duration::from_secs(42), "honours retry-after");
3383 }
3384 other => panic!("expected a lockout, got {other:?}"),
3385 }
3386 assert!(client.is_locked_out());
3387 }
3388
3389 #[tokio::test]
3390 async fn a_403_with_no_preceding_401_is_a_permissions_answer_not_a_lockout() {
3391 let server = MockServer::start().await;
3392 Mock::given(method("GET"))
3393 .and(path("/orgs/acme/actions/runners"))
3394 .respond_with(
3395 ResponseTemplate::new(403)
3396 .set_body_json(json!({"message": "Resource not accessible by integration"})),
3397 )
3398 .mount(&server)
3399 .await;
3400
3401 let client = client(&server, Arc::new(TestClock::default()));
3402 let err = client
3403 .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3404 .await
3405 .expect_err("403");
3406
3407 assert!(matches!(err, GithubError::Forbidden { .. }), "{err:?}");
3408 assert!(!err.is_lockout());
3409 assert!(!err.is_authentication());
3410 assert!(!client.is_locked_out(), "a permissions 403 must not latch");
3411 }
3412
3413 #[tokio::test]
3414 async fn a_locked_out_client_issues_no_further_http_until_the_backoff_elapses() {
3415 let server = MockServer::start().await;
3416 Mock::given(method("GET"))
3417 .and(path("/orgs/acme/actions/runners"))
3418 .respond_with(Script::new(vec![
3419 ResponseTemplate::new(401),
3420 ResponseTemplate::new(403).insert_header("retry-after", "60"),
3421 ResponseTemplate::new(200).set_body_json(json!({"total_count": 0})),
3422 ]))
3423 .mount(&server)
3424 .await;
3425 Mock::given(method("GET"))
3426 .and(path("/user/installations"))
3427 .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3428 .mount(&server)
3429 .await;
3430
3431 let clock = Arc::new(TestClock::default());
3432 let client = client(&server, Arc::clone(&clock));
3433 let request = ApiRequest::get("/orgs/acme/actions/runners");
3434
3435 let err = client.send(&request).await.expect_err("locks out");
3436 assert!(err.is_lockout(), "{err:?}");
3437
3438 let after_lockout = server.received_requests().await.unwrap().len();
3439
3440 for _ in 0..3 {
3441 let err = client.send(&request).await.expect_err("still locked out");
3442 assert!(err.is_lockout(), "{err:?}");
3443 }
3444 assert_eq!(
3445 server.received_requests().await.unwrap().len(),
3446 after_lockout,
3447 "a backed-off client must open no sockets at all"
3448 );
3449
3450 clock.advance_secs(61);
3451 assert!(!client.is_locked_out(), "the back-off expires on the clock");
3452 let response = client.send(&request).await.expect("traffic resumes");
3453 assert_eq!(response.status(), StatusCode::OK);
3454 assert_eq!(
3455 server.received_requests().await.unwrap().len(),
3456 after_lockout + 1
3457 );
3458 }
3459
3460 /// `consecutive_unauthorized` is reset only by a successful *caller*
3461 /// response, so a request ending in `404`, `422` or `5xx` leaves it set —
3462 /// and in the agent's long-lived reconciliation loop it stays set for as
3463 /// long as nothing succeeds. Before the fix, the next genuine permissions
3464 /// `403` was therefore reported as `AuthenticationLockout`: sixty seconds of
3465 /// client silence, plus an operator message asserting "the credential itself
3466 /// is not the problem" about a credential that was missing
3467 /// `Administration: write` — the failure `04-subsystem-contracts.md` names
3468 /// as the *expected* one for `generate-jitconfig`.
3469 ///
3470 /// The lockout's real signature is narrower: a `403` on the one retry this
3471 /// client issues after this request's own `401`, or a `403` whose own
3472 /// headers and body say GitHub is continuing a lockout. Neither is "the
3473 /// count is non-zero", which is what a stale `401` leaves behind — so the
3474 /// permissions `403` below is a permissions answer whatever happened minutes
3475 /// ago, and it is the *response*, not the history, that decides.
3476 #[tokio::test]
3477 async fn a_stale_401_does_not_turn_a_later_permissions_403_into_a_lockout() {
3478 let server = MockServer::start().await;
3479 // The first request ends in a 404, which leaves the 401 count set
3480 // because only a 2xx clears it.
3481 Mock::given(method("GET"))
3482 .and(path("/orgs/acme/actions/runners"))
3483 .respond_with(Script::new(vec![
3484 ResponseTemplate::new(401),
3485 ResponseTemplate::new(404).set_body_json(json!({"message": "Not Found"})),
3486 ]))
3487 .mount(&server)
3488 .await;
3489 Mock::given(method("GET"))
3490 .and(path("/user/installations"))
3491 .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3492 .mount(&server)
3493 .await;
3494 // Minutes later, a different call is denied for a missing permission.
3495 Mock::given(method("POST"))
3496 .and(path("/orgs/acme/actions/runners/generate-jitconfig"))
3497 .respond_with(
3498 ResponseTemplate::new(403)
3499 .set_body_json(json!({"message": "Resource not accessible by integration"})),
3500 )
3501 .mount(&server)
3502 .await;
3503
3504 let client = client(&server, Arc::new(TestClock::default()));
3505 let err = client
3506 .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3507 .await
3508 .expect_err("404");
3509 assert!(
3510 matches!(err, GithubError::Status { status: 404, .. }),
3511 "{err:?}"
3512 );
3513
3514 let err = client
3515 .send(&ApiRequest::new(
3516 Method::POST,
3517 "/orgs/acme/actions/runners/generate-jitconfig",
3518 ))
3519 .await
3520 .expect_err("403");
3521
3522 assert!(
3523 matches!(err, GithubError::Forbidden { .. }),
3524 "a fresh first-attempt 403 is a permissions answer, not a lockout: {err:?}"
3525 );
3526 assert!(!err.is_lockout());
3527 assert!(
3528 !client.is_locked_out(),
3529 "a stale 401 must not be able to silence the client for a minute"
3530 );
3531 }
3532
3533 /// GitHub's own rate limit is not an answer about the credential, and must
3534 /// not be reported as one. `classify` reached the `403` branch before
3535 /// anything looked at the rate-limit headers, so a primary rate limit
3536 /// arriving during a `401` storm was announced as an authentication lockout
3537 /// with the message "the credential itself is not the problem" — about a
3538 /// response that never mentioned the credential.
3539 #[tokio::test]
3540 async fn a_rate_limited_403_is_not_reported_as_an_authentication_lockout() {
3541 let server = MockServer::start().await;
3542 Mock::given(method("GET"))
3543 .and(path("/orgs/acme/actions/runners"))
3544 .respond_with(Script::new(vec![
3545 ResponseTemplate::new(401),
3546 ResponseTemplate::new(403)
3547 .insert_header("x-ratelimit-remaining", "0")
3548 .insert_header("x-ratelimit-reset", "1787270460")
3549 .insert_header("retry-after", "30")
3550 .set_body_json(json!({"message": "API rate limit exceeded"})),
3551 ]))
3552 .mount(&server)
3553 .await;
3554 Mock::given(method("GET"))
3555 .and(path("/user/installations"))
3556 .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3557 .mount(&server)
3558 .await;
3559
3560 let client = client(&server, Arc::new(TestClock::default()));
3561 let err = client
3562 .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3563 .await
3564 .expect_err("rate limited");
3565
3566 assert!(
3567 matches!(err, GithubError::Forbidden { .. }),
3568 "a rate limit is not an authentication outcome: {err:?}"
3569 );
3570 assert!(!err.is_lockout());
3571 assert!(!err.is_authentication());
3572 assert!(
3573 !client.is_locked_out(),
3574 "a rate limit must not latch this crate's authentication back-off"
3575 );
3576
3577 // And `c3` gets the evidence it needs to apply the policy that is its
3578 // own, without editing this file.
3579 let evidence = err
3580 .rate_limit()
3581 .expect("the headers survived classification");
3582 assert_eq!(evidence.remaining, Some(0));
3583 assert_eq!(evidence.reset_unix_secs, Some(1_787_270_460));
3584 assert_eq!(evidence.retry_after, Some(Duration::from_secs(30)));
3585 }
3586
3587 /// The same claim for the variant `429` lands in.
3588 #[tokio::test]
3589 async fn a_429_carries_its_retry_after_across_the_c2_c3_seam() {
3590 let server = MockServer::start().await;
3591 Mock::given(method("GET"))
3592 .and(path("/orgs/acme/actions/runners"))
3593 .respond_with(
3594 ResponseTemplate::new(429)
3595 .insert_header("retry-after", "17")
3596 .insert_header("x-ratelimit-remaining", "0")
3597 .set_body_json(json!({"message": "You have exceeded a secondary rate limit"})),
3598 )
3599 .mount(&server)
3600 .await;
3601
3602 let client = client(&server, Arc::new(TestClock::default()));
3603 let err = client
3604 .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3605 .await
3606 .expect_err("429");
3607
3608 assert!(
3609 matches!(err, GithubError::Status { status: 429, .. }),
3610 "{err:?}"
3611 );
3612 assert_eq!(
3613 err.retry_after(),
3614 Some(Duration::from_secs(17)),
3615 "destroying this header is what made `c3`'s Definition of Done unmeetable"
3616 );
3617 assert_eq!(
3618 err.headers().and_then(|h| h.get("x-ratelimit-remaining")),
3619 Some(&reqwest::header::HeaderValue::from_static("0"))
3620 );
3621 }
3622
3623 /// A back-off is a safety mechanism, and this one had both failure modes at
3624 /// once: no ceiling, so `Retry-After: 86400` latched a silent twenty-four
3625 /// hour outage; and `TimeDelta::from_std(...).ok()` on a value too large to
3626 /// convert, which yielded `until = None` — *not locked out at all*, the
3627 /// exact inverse of the requirement, reachable by a header alone.
3628 #[tokio::test]
3629 async fn an_extreme_retry_after_is_clamped_and_never_fails_open() {
3630 async fn lockout_for(header: &str) -> (GithubError, bool, Option<Duration>) {
3631 let server = MockServer::start().await;
3632 Mock::given(method("GET"))
3633 .and(path("/orgs/acme/actions/runners"))
3634 .respond_with(Script::new(vec![
3635 ResponseTemplate::new(401),
3636 ResponseTemplate::new(403).insert_header("retry-after", header),
3637 ]))
3638 .mount(&server)
3639 .await;
3640 Mock::given(method("GET"))
3641 .and(path("/user/installations"))
3642 .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3643 .mount(&server)
3644 .await;
3645
3646 let client = AuthenticatedClient::new(
3647 Endpoints::for_test_server(&server.uri()).unwrap(),
3648 UserAccessToken::new(SecretString::from(FIXTURE_TOKEN)),
3649 Arc::new(TestClock::default()),
3650 )
3651 .unwrap();
3652 let err = client
3653 .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3654 .await
3655 .expect_err("403 after a 401");
3656 let locked = client.is_locked_out();
3657 let remaining = client.lockout_remaining();
3658 (err, locked, remaining)
3659 }
3660
3661 // A day-long back-off is clamped to the ceiling.
3662 let (err, locked, remaining) = lockout_for("86400").await;
3663 let GithubError::AuthenticationLockout { retry_after } = &err else {
3664 panic!("expected a lockout, got {err:?}");
3665 };
3666 assert_eq!(
3667 *retry_after, MAX_LOCKOUT_BACKOFF,
3668 "an unclamped Retry-After lets a remote party decide how long this product \
3669 stays down"
3670 );
3671 assert!(locked);
3672 assert!(remaining.is_some_and(|r| r <= MAX_LOCKOUT_BACKOFF));
3673
3674 // A value too large for `chrono` must still lock out. Before the fix
3675 // this produced `until = None`: the more extreme the header, the less
3676 // protection it bought.
3677 let (err, locked, remaining) = lockout_for(&u64::MAX.to_string()).await;
3678 assert!(err.is_lockout(), "{err:?}");
3679 assert!(
3680 locked,
3681 "an absurd Retry-After must not mean `not locked out at all` — that fails open"
3682 );
3683 assert!(remaining.is_some_and(|r| r <= MAX_LOCKOUT_BACKOFF));
3684 }
3685
3686 /// The lockout's own contract is "this client issues no HTTP at all", and
3687 /// `revalidate` is HTTP. It documented an `AuthenticationLockout` it could
3688 /// never return, which made the one direct entry point into the probe the
3689 /// single exception to the rule.
3690 #[tokio::test]
3691 async fn a_direct_revalidation_is_refused_while_the_lockout_is_backing_off() {
3692 let server = MockServer::start().await;
3693 Mock::given(method("GET"))
3694 .and(path("/orgs/acme/actions/runners"))
3695 .respond_with(Script::new(vec![
3696 ResponseTemplate::new(401),
3697 ResponseTemplate::new(403).insert_header("retry-after", "60"),
3698 ]))
3699 .mount(&server)
3700 .await;
3701 Mock::given(method("GET"))
3702 .and(path("/user/installations"))
3703 .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3704 .mount(&server)
3705 .await;
3706
3707 let client = client(&server, Arc::new(TestClock::default()));
3708 client
3709 .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3710 .await
3711 .expect_err("locks out");
3712 assert!(client.is_locked_out());
3713
3714 let before = server.received_requests().await.unwrap().len();
3715 let err = client
3716 .revalidate()
3717 .await
3718 .expect_err("the documented lockout error is now reachable");
3719 assert!(err.is_lockout(), "{err:?}");
3720 assert_eq!(
3721 server.received_requests().await.unwrap().len(),
3722 before,
3723 "a locked-out client opens no socket, and the probe is not an exception"
3724 );
3725 }
3726
3727 /// The position rule fixed `classify` and left the same defect one function
3728 /// over, behind a comment asserting it could not happen: "the probe only
3729 /// ever runs after a `401`, so it is always in the retry position". Making
3730 /// [`AuthenticatedClient::revalidate`] public — the previous round's own
3731 /// change — is exactly what made that untrue.
3732 ///
3733 /// The sequence is the agent's, not a contrivance. A request 401s, the probe
3734 /// says the credential is fine, the retry answers `404` — which does *not*
3735 /// reset the counter, by design. Minutes later `f1` renders `auth status`,
3736 /// which probes directly, and the probe meets an ordinary permissions `403`.
3737 /// A stale `401` then latched a sixty-second client-wide lockout and told
3738 /// the operator to wait, when the real answer was a missing grant.
3739 #[tokio::test]
3740 async fn a_directly_requested_probe_does_not_latch_a_lockout_from_a_stale_401() {
3741 let server = MockServer::start().await;
3742 Mock::given(method("GET"))
3743 .and(path("/orgs/acme/actions/runners"))
3744 .respond_with(Script::new(vec![
3745 ResponseTemplate::new(401),
3746 // The retry misses. A `404` leaves `consecutive_unauthorized`
3747 // set, which is the whole premise of the position rule.
3748 ResponseTemplate::new(404).set_body_json(json!({"message": "Not Found"})),
3749 ]))
3750 .mount(&server)
3751 .await;
3752 Mock::given(method("GET"))
3753 .and(path("/user/installations"))
3754 .respond_with(Script::new(vec![
3755 // The probe that accompanies the 401 above.
3756 ResponseTemplate::new(200).set_body_json(installations_body(&[])),
3757 // The direct probe, minutes later: a plain permissions answer,
3758 // with no `retry-after` and a message that names a grant.
3759 ResponseTemplate::new(403)
3760 .set_body_json(json!({"message": "Resource not accessible by integration"})),
3761 ]))
3762 .mount(&server)
3763 .await;
3764
3765 let clock = Arc::new(TestClock::default());
3766 let client = client(&server, clock.clone());
3767 client
3768 .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3769 .await
3770 .expect_err("the retry 404s");
3771 assert!(
3772 !client.is_locked_out(),
3773 "a 404 on the retry is not a lockout"
3774 );
3775
3776 clock.advance_secs(300);
3777 let outcome = client
3778 .revalidate()
3779 .await
3780 .expect("a direct probe is not a lockout error");
3781
3782 assert_eq!(
3783 outcome,
3784 Revalidation::Unavailable,
3785 "a 403 on the probe teaches this client nothing about the credential"
3786 );
3787 assert!(
3788 !client.is_locked_out(),
3789 "a caller-initiated probe is a *first* attempt, not the retry that follows a 401: \
3790 latching here converts a stale 401 into a 60-second client-wide outage and \
3791 reports a missing permission as `the credential is fine, please wait`"
3792 );
3793 assert_eq!(client.lockout_remaining(), None);
3794 }
3795
3796 /// The square the other three leave empty, and the one where a defect in the
3797 /// composition would hide.
3798 ///
3799 /// Covered elsewhere: a first-attempt continuation through `send`, a
3800 /// first-attempt permissions `403` through `send`, and a direct probe
3801 /// meeting a permissions `403` (immediately above). A direct probe meeting a
3802 /// *continuation-shaped* `403` is the fourth square, and it is the
3803 /// composition point of the two rules that pull in opposite directions —
3804 /// the narrowing to `Attempt::First` that fixed the stale-`401` lockout, and
3805 /// the continuation rule that re-widens `First` on GitHub's own evidence.
3806 ///
3807 /// If the narrowing swallowed the continuation here, every other test in
3808 /// this file would still pass, and `f1`'s `auth status` would poll a
3809 /// credential GitHub had asked it to leave alone — reporting each refusal as
3810 /// a missing grant.
3811 #[tokio::test]
3812 async fn a_directly_requested_probe_latches_a_continuation_shaped_lockout() {
3813 let server = MockServer::start().await;
3814 Mock::given(method("GET"))
3815 .and(path("/user/installations"))
3816 // The continuation's signature: `retry-after`, and a body with no
3817 // message for `error_message` to find.
3818 .respond_with(ResponseTemplate::new(403).insert_header("retry-after", "60"))
3819 .mount(&server)
3820 .await;
3821
3822 let client = client(&server, Arc::new(TestClock::default()));
3823
3824 // No preceding traffic whatsoever: a first attempt in the strongest
3825 // sense, which is exactly the position the narrowed rule refused to
3826 // latch in.
3827 let err = client
3828 .revalidate()
3829 .await
3830 .expect_err("a probe that latches a lockout reports it rather than `Unavailable`");
3831 assert!(
3832 err.is_lockout(),
3833 "`revalidate` latched a client-wide lockout and must say so; answering \
3834 `Ok(Unavailable)` leaves `f1` to discover a 15-minute outage through a separate \
3835 `is_locked_out()` call it has no reason to make: {err:?}"
3836 );
3837 assert!(
3838 client.is_locked_out(),
3839 "a 403 carrying `retry-after` with no message is GitHub continuing a lockout, \
3840 whoever asked for the request that met it"
3841 );
3842 assert_eq!(client.lockout_remaining(), Some(Duration::from_secs(60)));
3843
3844 // And the back-off is real, not just a renamed error.
3845 let before = server.received_requests().await.unwrap().len();
3846 let err = client.revalidate().await.expect_err("still locked out");
3847 assert!(err.is_lockout(), "{err:?}");
3848 assert_eq!(
3849 server.received_requests().await.unwrap().len(),
3850 before,
3851 "latching must actually stop traffic"
3852 );
3853 }
3854
3855 /// [`retry_after`] parses integer seconds only; RFC 9110 §10.2.3 also
3856 /// permits an HTTP-date. Detection used to gate on that parse succeeding, so
3857 /// a date-form `Retry-After` was not recognised as a continuation at all and
3858 /// the hole the continuation rule exists to close reopened for that shape —
3859 /// silently, because the response leaves as an ordinary `Forbidden`.
3860 ///
3861 /// GitHub sends integer seconds in practice. This pins the crate to not
3862 /// depending on that, and records where the two halves part company:
3863 /// presence decides *whether* it is a lockout, the integer parse decides
3864 /// only *how long*, and `latch_lockout` already had a default for the header
3865 /// it could not read.
3866 #[tokio::test]
3867 async fn a_date_form_retry_after_is_still_recognised_as_a_continuation() {
3868 let server = MockServer::start().await;
3869 Mock::given(method("GET"))
3870 .and(path("/user/installations"))
3871 .respond_with(
3872 ResponseTemplate::new(403)
3873 .insert_header("retry-after", "Wed, 21 Oct 2026 07:28:00 GMT"),
3874 )
3875 .mount(&server)
3876 .await;
3877
3878 let client = client(&server, Arc::new(TestClock::default()));
3879 let err = client
3880 .revalidate()
3881 .await
3882 .expect_err("a date-form `retry-after` is still GitHub asking to be left alone");
3883 assert!(
3884 err.is_lockout(),
3885 "gating detection on an integer parse hands the continuation bug back for every \
3886 lockout GitHub chose to date-stamp: {err:?}"
3887 );
3888 assert_eq!(
3889 client.lockout_remaining(),
3890 Some(DEFAULT_LOCKOUT_BACKOFF),
3891 "the date form is recognised for detection; the duration falls back to the \
3892 default, which is what `latch_lockout` already did with a header it could not \
3893 parse as seconds"
3894 );
3895 }
3896
3897 /// The narrowing that fixed the stale-`401` lockout opened a hole at the
3898 /// other end of the same back-off.
3899 ///
3900 /// While GitHub is still locking the credential out after the back-off
3901 /// elapses, the next request is a *first* attempt by construction — this
3902 /// client's own retry never happened, because the request never reached the
3903 /// wire. So the position rule declined to call it a lockout and `classify`
3904 /// fell through to [`GithubError::Forbidden`], whose documented reading is
3905 /// "the App installation does not grant it". The client then stopped backing
3906 /// off entirely and hammered a credential GitHub had asked it to leave
3907 /// alone, which is the exact inverse of "backs off without retrying".
3908 ///
3909 /// A continuation is distinguishable from a permissions answer without any
3910 /// counter: GitHub sends `retry-after` and no message body for the lockout,
3911 /// and a message and no `retry-after` for a permissions refusal.
3912 #[tokio::test]
3913 async fn a_lockout_outliving_its_backoff_re_latches_instead_of_reporting_a_permissions_answer()
3914 {
3915 let server = MockServer::start().await;
3916 Mock::given(method("GET"))
3917 .and(path("/orgs/acme/actions/runners"))
3918 .respond_with(Script::new(vec![
3919 ResponseTemplate::new(401),
3920 // The retry: the lockout latches here, in the retry position.
3921 ResponseTemplate::new(403).insert_header("retry-after", "60"),
3922 // The continuation, once the back-off has elapsed. Same shape,
3923 // first position.
3924 ResponseTemplate::new(403).insert_header("retry-after", "60"),
3925 ]))
3926 .mount(&server)
3927 .await;
3928 Mock::given(method("GET"))
3929 .and(path("/user/installations"))
3930 .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
3931 .mount(&server)
3932 .await;
3933
3934 let clock = Arc::new(TestClock::default());
3935 let client = client(&server, clock.clone());
3936 let err = client
3937 .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3938 .await
3939 .expect_err("403 on the retry");
3940 assert!(err.is_lockout(), "{err:?}");
3941 assert!(client.is_locked_out());
3942
3943 // The back-off elapses with GitHub unchanged.
3944 clock.advance_secs(61);
3945 assert!(!client.is_locked_out(), "the back-off has run out");
3946
3947 let err = client
3948 .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3949 .await
3950 .expect_err("GitHub is still locking the credential out");
3951
3952 assert!(
3953 err.is_lockout(),
3954 "a 403 carrying `retry-after` with no message is GitHub continuing the lockout, \
3955 not the App installation refusing a permission; reporting `Forbidden` here \
3956 tells the operator to fix a grant that is not missing: {err:?}"
3957 );
3958 assert!(
3959 client.is_locked_out(),
3960 "`backs off without retrying` fails for any lockout that outlives one back-off \
3961 if the continuation does not re-latch"
3962 );
3963 let GithubError::AuthenticationLockout { retry_after } = err else {
3964 unreachable!("asserted above")
3965 };
3966 assert_eq!(
3967 retry_after,
3968 Duration::from_secs(60),
3969 "the continuation's own `retry-after` sets the new back-off"
3970 );
3971
3972 // And the next request is suppressed before a socket is opened, which is
3973 // the property the whole back-off exists for.
3974 let before = server.received_requests().await.unwrap().len();
3975 let err = client
3976 .send(&ApiRequest::get("/orgs/acme/actions/runners"))
3977 .await
3978 .expect_err("still locked out");
3979 assert!(err.is_lockout(), "{err:?}");
3980 assert_eq!(
3981 server.received_requests().await.unwrap().len(),
3982 before,
3983 "re-latching must actually stop traffic, not merely rename the error"
3984 );
3985 }
3986
3987 /// A permissions `403` on a first attempt is still a permissions answer, and
3988 /// the continuation rule above must not swallow it. This is the test that
3989 /// keeps that rule from becoming "every 403 is a lockout".
3990 #[tokio::test]
3991 async fn a_first_attempt_permissions_403_is_still_reported_as_forbidden() {
3992 let server = MockServer::start().await;
3993 Mock::given(method("GET"))
3994 .and(path("/orgs/acme/actions/runners"))
3995 .respond_with(
3996 ResponseTemplate::new(403)
3997 .set_body_json(json!({"message": "Resource not accessible by integration"})),
3998 )
3999 .mount(&server)
4000 .await;
4001
4002 let client = client(&server, Arc::new(TestClock::default()));
4003 let err = client
4004 .send(&ApiRequest::get("/orgs/acme/actions/runners"))
4005 .await
4006 .expect_err("403");
4007
4008 assert!(
4009 matches!(err, GithubError::Forbidden { .. }),
4010 "a message and no `retry-after` is GitHub naming a missing grant: {err:?}"
4011 );
4012 assert!(!client.is_locked_out());
4013 }
4014
4015 /// The `consecutive_unauthorized > 0` conjunct that used to sit alongside
4016 /// the position rule added no signal — `Attempt::Retry` already implies this
4017 /// request's own `401` incremented the counter — and added a fail-open race:
4018 /// any concurrent success `store(0)`s the counter between the `401` and the
4019 /// retry, and a real lockout is then reported as a permissions answer.
4020 ///
4021 /// The race is driven directly rather than by scheduling two requests and
4022 /// hoping: `store(0)` is the *only* thing the concurrent success contributes,
4023 /// so performing it between the `401` and the classification reproduces the
4024 /// race deterministically and on every run.
4025 #[tokio::test]
4026 async fn a_concurrent_success_cannot_downgrade_a_lockout_to_a_permissions_answer() {
4027 let server = MockServer::start().await;
4028 let client = client(&server, Arc::new(TestClock::default()));
4029
4030 // This request's own 401 has landed: the retry position is established.
4031 client
4032 .consecutive_unauthorized
4033 .fetch_add(1, Ordering::SeqCst);
4034 // ... and a request on another task succeeds in the same instant.
4035 client.consecutive_unauthorized.store(0, Ordering::SeqCst);
4036
4037 let mut headers = HeaderMap::new();
4038 headers.insert("retry-after", "60".parse().unwrap());
4039 let lockout = ApiResponse {
4040 status: StatusCode::FORBIDDEN,
4041 headers,
4042 body: Vec::new(),
4043 };
4044
4045 assert!(
4046 client.is_lockout_403(&lockout, Attempt::Retry),
4047 "`Attempt::Retry` already means this request's own 401 incremented the counter, so \
4048 reading the counter again adds no signal and only lets an unrelated success \
4049 downgrade a real lockout to `Forbidden`"
4050 );
4051
4052 // The counter must stay irrelevant in the other direction too: a
4053 // permissions `403` on a first attempt is not a lockout however many
4054 // `401`s are on the count.
4055 client.consecutive_unauthorized.store(7, Ordering::SeqCst);
4056 let permissions = ApiResponse {
4057 status: StatusCode::FORBIDDEN,
4058 headers: HeaderMap::new(),
4059 body: br#"{"message":"Resource not accessible by integration"}"#.to_vec(),
4060 };
4061 assert!(!client.is_lockout_403(&permissions, Attempt::First));
4062 }
4063
4064 // -- installation discovery ---------------------------------------------
4065
4066 #[tokio::test]
4067 async fn discovery_returns_the_reachable_repository_and_organization_set() {
4068 let server = MockServer::start().await;
4069 Mock::given(method("GET"))
4070 .and(path("/user/installations"))
4071 .respond_with(
4072 ResponseTemplate::new(200).set_body_json(installations_body(&[
4073 (11, "IvanMurzak", "User", "selected"),
4074 (22, "Tap-Top-Fun", "Organization", "all"),
4075 ])),
4076 )
4077 .mount(&server)
4078 .await;
4079 Mock::given(method("GET"))
4080 .and(path("/user/installations/11/repositories"))
4081 .respond_with(
4082 ResponseTemplate::new(200)
4083 .set_body_json(repositories_body(&["IvanMurzak/GitHub-Runner-Scaler-UI"])),
4084 )
4085 .mount(&server)
4086 .await;
4087 Mock::given(method("GET"))
4088 .and(path("/user/installations/22/repositories"))
4089 .respond_with(
4090 ResponseTemplate::new(200)
4091 .set_body_json(repositories_body(&["Tap-Top-Fun/game", "Tap-Top-Fun/site"])),
4092 )
4093 .mount(&server)
4094 .await;
4095
4096 let client = client(&server, Arc::new(TestClock::default()));
4097 let discovery = client.discover_installations(&app()).await.unwrap();
4098
4099 let targets = discovery.targets().expect("installed");
4100 assert_eq!(
4101 targets
4102 .repositories()
4103 .iter()
4104 .map(ToString::to_string)
4105 .collect::<Vec<_>>(),
4106 [
4107 "IvanMurzak/GitHub-Runner-Scaler-UI",
4108 "Tap-Top-Fun/game",
4109 "Tap-Top-Fun/site"
4110 ]
4111 );
4112 assert_eq!(
4113 targets
4114 .organizations()
4115 .iter()
4116 .map(ToString::to_string)
4117 .collect::<Vec<_>>(),
4118 ["Tap-Top-Fun"],
4119 "a User account is not an organization target"
4120 );
4121 assert!(discovery.install_url().is_none());
4122 }
4123
4124 #[tokio::test]
4125 async fn an_over_broad_installation_is_visible_rather_than_assumed() {
4126 let server = MockServer::start().await;
4127 Mock::given(method("GET"))
4128 .and(path("/user/installations"))
4129 .respond_with(
4130 ResponseTemplate::new(200).set_body_json(installations_body(&[
4131 (11, "IvanMurzak", "User", "selected"),
4132 (22, "Tap-Top-Fun", "Organization", "all"),
4133 ])),
4134 )
4135 .mount(&server)
4136 .await;
4137 Mock::given(method("GET"))
4138 .and(path("/user/installations/11/repositories"))
4139 .respond_with(ResponseTemplate::new(200).set_body_json(repositories_body(&["a/b"])))
4140 .mount(&server)
4141 .await;
4142 Mock::given(method("GET"))
4143 .and(path("/user/installations/22/repositories"))
4144 .respond_with(ResponseTemplate::new(200).set_body_json(repositories_body(&["c/d"])))
4145 .mount(&server)
4146 .await;
4147
4148 let client = client(&server, Arc::new(TestClock::default()));
4149 let targets = client
4150 .discover_installations(&app())
4151 .await
4152 .unwrap()
4153 .targets()
4154 .cloned()
4155 .expect("installed");
4156
4157 let over_broad = targets.over_broad();
4158 assert_eq!(over_broad.len(), 1);
4159 assert_eq!(over_broad[0].account.login(), "Tap-Top-Fun");
4160 assert!(over_broad[0].is_over_broad());
4161 assert_eq!(
4162 over_broad[0].repository_selection,
4163 RepositorySelection::All,
4164 "`repository_selection: all` reaches repositories created later too"
4165 );
4166 assert!(
4167 targets.installations().iter().any(|i| i
4168 .permissions
4169 .iter()
4170 .any(|(k, v)| k == "administration" && v == "write")),
4171 "the grant GitHub reports is surfaced verbatim, not assumed from the design"
4172 );
4173 }
4174
4175 #[tokio::test]
4176 async fn discovery_returns_the_installation_url_when_the_set_is_empty() {
4177 let server = MockServer::start().await;
4178 Mock::given(method("GET"))
4179 .and(path("/user/installations"))
4180 .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
4181 .mount(&server)
4182 .await;
4183
4184 let client = client(&server, Arc::new(TestClock::default()));
4185 let discovery = client.discover_installations(&app()).await.unwrap();
4186
4187 let url = discovery
4188 .install_url()
4189 .expect("an empty set must yield the installation URL");
4190 assert_eq!(url.path(), "/apps/runner-manager/installations/new");
4191 assert!(discovery.targets().is_none());
4192 }
4193
4194 #[tokio::test]
4195 async fn an_installation_that_reaches_no_repository_is_still_not_installed() {
4196 let server = MockServer::start().await;
4197 Mock::given(method("GET"))
4198 .and(path("/user/installations"))
4199 .respond_with(
4200 ResponseTemplate::new(200).set_body_json(installations_body(&[(
4201 11,
4202 "IvanMurzak",
4203 "User",
4204 "selected",
4205 )])),
4206 )
4207 .mount(&server)
4208 .await;
4209 Mock::given(method("GET"))
4210 .and(path("/user/installations/11/repositories"))
4211 .respond_with(ResponseTemplate::new(200).set_body_json(repositories_body(&[])))
4212 .mount(&server)
4213 .await;
4214
4215 let client = client(&server, Arc::new(TestClock::default()));
4216 let discovery = client.discover_installations(&app()).await.unwrap();
4217 assert!(
4218 discovery.install_url().is_some(),
4219 "a user installation that selected no repository reaches nothing"
4220 );
4221 }
4222
4223 #[tokio::test]
4224 async fn discovery_follows_every_page_rather_than_trusting_the_first() {
4225 let server = MockServer::start().await;
4226 let next = format!("<{}/user/installations?page=2>; rel=\"next\"", server.uri());
4227 Mock::given(method("GET"))
4228 .and(path("/user/installations"))
4229 .respond_with(Script::new(vec![
4230 ResponseTemplate::new(200)
4231 .set_body_json(installations_body(&[(
4232 11,
4233 "one",
4234 "Organization",
4235 "selected",
4236 )]))
4237 .insert_header("link", next.as_str()),
4238 ResponseTemplate::new(200).set_body_json(installations_body(&[(
4239 22,
4240 "two",
4241 "Organization",
4242 "selected",
4243 )])),
4244 ]))
4245 .mount(&server)
4246 .await;
4247 Mock::given(method("GET"))
4248 .and(path("/user/installations/11/repositories"))
4249 .respond_with(ResponseTemplate::new(200).set_body_json(repositories_body(&["one/a"])))
4250 .mount(&server)
4251 .await;
4252 Mock::given(method("GET"))
4253 .and(path("/user/installations/22/repositories"))
4254 .respond_with(ResponseTemplate::new(200).set_body_json(repositories_body(&["two/b"])))
4255 .mount(&server)
4256 .await;
4257
4258 let client = client(&server, Arc::new(TestClock::default()));
4259 let targets = client
4260 .discover_installations(&app())
4261 .await
4262 .unwrap()
4263 .targets()
4264 .cloned()
4265 .expect("installed");
4266 assert_eq!(
4267 targets
4268 .organizations()
4269 .iter()
4270 .map(ToString::to_string)
4271 .collect::<Vec<_>>(),
4272 ["one", "two"],
4273 "the second page must not be dropped"
4274 );
4275 }
4276
4277 /// GitHub's published `installation` schema types `account` as **nullable**,
4278 /// and as either a simple-user *or* an enterprise — which carries
4279 /// `slug`/`name` where a user carries `login`. A required `RawAccount` with
4280 /// a required `login` made either shape a hard `response.json()` failure,
4281 /// which takes down all of `discover_installations`, which is all of
4282 /// `auth status`. One unusual installation must not blind the command that
4283 /// exists to show the user what their credential can reach.
4284 #[tokio::test]
4285 async fn an_installation_with_a_null_or_enterprise_account_does_not_fail_the_whole_decode() {
4286 let server = MockServer::start().await;
4287 Mock::given(method("GET"))
4288 .and(path("/user/installations"))
4289 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
4290 "total_count": 3,
4291 "installations": [
4292 // Nullable, per the published schema.
4293 { "id": 10, "account": null, "repository_selection": "selected" },
4294 // An enterprise: no `login` at all.
4295 {
4296 "id": 20,
4297 "account": { "slug": "acme-enterprise", "name": "Acme Inc" },
4298 "repository_selection": "selected"
4299 },
4300 // And an ordinary user alongside them.
4301 {
4302 "id": 30,
4303 "account": { "login": "IvanMurzak", "type": "User" },
4304 "repository_selection": "selected"
4305 }
4306 ]
4307 })))
4308 .mount(&server)
4309 .await;
4310 for (id, repo) in [(20_u64, "acme-enterprise/tools"), (30, "IvanMurzak/app")] {
4311 Mock::given(method("GET"))
4312 .and(path(format!("/user/installations/{id}/repositories")))
4313 .respond_with(ResponseTemplate::new(200).set_body_json(repositories_body(&[repo])))
4314 .mount(&server)
4315 .await;
4316 }
4317
4318 let client = client(&server, Arc::new(TestClock::default()));
4319 let targets = client
4320 .discover_installations(&app())
4321 .await
4322 .expect("one odd account must not fail the whole discovery")
4323 .targets()
4324 .cloned()
4325 .expect("installed");
4326
4327 // Membership rather than order: what matters here is that neither
4328 // installation was lost, not how `OwnerRepo` collates.
4329 let reached = targets
4330 .repositories()
4331 .iter()
4332 .map(ToString::to_string)
4333 .collect::<Vec<_>>();
4334 assert!(
4335 reached.contains(&"acme-enterprise/tools".to_string()),
4336 "the enterprise installation is named from `slug` rather than dropped: {reached:?}"
4337 );
4338 assert!(
4339 reached.contains(&"IvanMurzak/app".to_string()),
4340 "the ordinary installation alongside it survives too: {reached:?}"
4341 );
4342 assert_eq!(reached.len(), 2);
4343 assert_eq!(
4344 targets.installations().len(),
4345 2,
4346 "the null account is skipped, and only it"
4347 );
4348 assert_eq!(
4349 targets.skipped(),
4350 1,
4351 "the skip is the right trade, but it must travel with the answer: everything the \
4352 skipped installation reaches is missing from the lists above, and a short list \
4353 reads exactly like a complete one"
4354 );
4355
4356 // The enterprise is labelled an enterprise. It used to fall through to
4357 // `User`, so `auth status` told the operator their enterprise was a
4358 // personal account.
4359 let enterprise = targets
4360 .installations()
4361 .iter()
4362 .find(|i| i.id == 20)
4363 .expect("the enterprise installation survived");
4364 assert_eq!(
4365 enterprise.account,
4366 InstallationAccount::Enterprise("acme-enterprise".to_string()),
4367 "an account with no `login` that names itself through `slug` is an enterprise, \
4368 and calling it a user is a wrong statement about the operator's own account"
4369 );
4370 assert_eq!(enterprise.account.kind(), "enterprise");
4371 assert!(
4372 enterprise.account.organization().is_none(),
4373 "an enterprise is not an organization target: `GET /orgs/{{org}}/actions/runners` \
4374 does not accept one, so contributing nothing to `organizations()` is correct"
4375 );
4376 assert!(
4377 !targets
4378 .organizations()
4379 .iter()
4380 .any(|o| o.as_str() == "acme-enterprise"),
4381 "and it must not be smuggled in as one either"
4382 );
4383 }
4384
4385 /// The skip is right; the verdict flip was not.
4386 ///
4387 /// A null-account installation that is the *only* installation used to
4388 /// collapse to `NotInstalled`, so `auth status` handed an operator who **is**
4389 /// installed the "install the App" URL — a wrong remediation on the only
4390 /// authentication path there is, contradicted by nothing but a `warn!`.
4391 #[tokio::test]
4392 async fn a_credential_whose_only_installation_was_skipped_is_not_reported_as_not_installed() {
4393 let server = MockServer::start().await;
4394 Mock::given(method("GET"))
4395 .and(path("/user/installations"))
4396 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
4397 "total_count": 1,
4398 "installations": [
4399 { "id": 10, "account": null, "repository_selection": "selected" }
4400 ]
4401 })))
4402 .mount(&server)
4403 .await;
4404
4405 let client = client(&server, Arc::new(TestClock::default()));
4406 let discovery = client.discover_installations(&app()).await.unwrap();
4407
4408 assert_eq!(
4409 discovery,
4410 InstallationDiscovery::Indeterminate { skipped: 1 },
4411 "GitHub reported an installation; this client could not describe it. That is not \
4412 the same answer as `not installed`, and only one of the two is fixed by \
4413 installing the App"
4414 );
4415 assert_eq!(
4416 discovery.install_url(),
4417 None,
4418 "offering the install URL here is the wrong remediation, and putting it one field \
4419 over from the right verdict would just relocate the defect"
4420 );
4421 assert_eq!(discovery.skipped(), 1);
4422 assert!(discovery.targets().is_none());
4423 }
4424
4425 /// The other side of the same rule: with nothing skipped, an empty reach is
4426 /// still an empty reach, and the install URL is still the remediation.
4427 #[tokio::test]
4428 async fn an_empty_reach_with_nothing_skipped_is_still_not_installed() {
4429 let server = MockServer::start().await;
4430 Mock::given(method("GET"))
4431 .and(path("/user/installations"))
4432 .respond_with(ResponseTemplate::new(200).set_body_json(installations_body(&[])))
4433 .mount(&server)
4434 .await;
4435
4436 let client = client(&server, Arc::new(TestClock::default()));
4437 let discovery = client.discover_installations(&app()).await.unwrap();
4438
4439 assert!(discovery.install_url().is_some(), "{discovery:?}");
4440 assert_eq!(discovery.skipped(), 0);
4441 }
4442
4443 /// A `Link: rel="next"` that points back at the page it arrived on is an
4444 /// infinite loop inside the agent's reconciliation loop — the one place in
4445 /// this product that must not be able to wedge. The ceiling is what makes
4446 /// this test terminate at all.
4447 #[tokio::test]
4448 async fn a_self_referential_link_header_stops_at_the_page_ceiling() {
4449 let server = MockServer::start().await;
4450 let self_link = format!("<{}/user/installations?page=2>; rel=\"next\"", server.uri());
4451 Mock::given(method("GET"))
4452 .and(path("/user/installations"))
4453 .respond_with(
4454 ResponseTemplate::new(200)
4455 .set_body_json(installations_body(&[]))
4456 .insert_header("link", self_link.as_str()),
4457 )
4458 .mount(&server)
4459 .await;
4460
4461 let client = client(&server, Arc::new(TestClock::default()));
4462 let discovery = client
4463 .discover_installations(&app())
4464 .await
4465 .expect("the ceiling is what makes this return at all");
4466
4467 assert!(
4468 discovery.install_url().is_some(),
4469 "no installation was found"
4470 );
4471 assert_eq!(
4472 server.received_requests().await.unwrap().len(),
4473 MAX_PAGES,
4474 "pagination must stop at the ceiling rather than follow the loop forever"
4475 );
4476 }
4477
4478 #[test]
4479 fn a_link_header_yields_only_the_next_relation() {
4480 let header = "<https://api.github.com/user/installations?page=3>; rel=\"next\", \
4481 <https://api.github.com/user/installations?page=9>; rel=\"last\"";
4482 assert_eq!(
4483 parse_link_next(header).map(|u| u.to_string()),
4484 Some("https://api.github.com/user/installations?page=3".to_string())
4485 );
4486 assert!(parse_link_next("<https://x/>; rel=\"last\"").is_none());
4487 assert!(parse_link_next("nonsense").is_none());
4488 }
4489
4490 /// A comma is legal inside a URL and GitHub sends such URLs routinely — a
4491 /// runner query carries `labels=self-hosted,windows`. Splitting the header
4492 /// on `,` before recognising `<...>` tore that URL in half, found no
4493 /// relation, and stopped paginating at page 1 while reporting success. That
4494 /// is precisely what `04-subsystem-contracts.md` forbids ("the dashboard
4495 /// must not treat a first page as a complete inventory"), in the one shared
4496 /// reader `c3`'s inventory also goes through.
4497 #[test]
4498 fn a_next_url_containing_a_comma_still_paginates() {
4499 let header = "<https://api.github.com/repos/o/r/actions/runners\
4500 ?labels=self-hosted,windows&page=2>; rel=\"next\"";
4501 assert_eq!(
4502 parse_link_next(header).map(|u| u.to_string()),
4503 Some(
4504 "https://api.github.com/repos/o/r/actions/runners\
4505 ?labels=self-hosted,windows&page=2"
4506 .to_string()
4507 ),
4508 "a comma inside the URL must not end the link-value"
4509 );
4510
4511 // The same URL as the second link-value, so the scan has to walk past a
4512 // comma-bearing target to reach the relation it wants.
4513 let header = "<https://api.github.com/x?a=1,2&page=1>; rel=\"prev\", \
4514 <https://api.github.com/x?a=1,2&page=3>; rel=\"next\"";
4515 assert_eq!(
4516 parse_link_next(header).map(|u| u.to_string()),
4517 Some("https://api.github.com/x?a=1,2&page=3".to_string())
4518 );
4519 }
4520
4521 /// `rel="next"` is not always the first link-value, and both quoted and
4522 /// unquoted forms are legal.
4523 #[test]
4524 fn the_next_relation_is_found_wherever_it_sits_in_the_header() {
4525 let not_first = "<https://api.github.com/u?page=1>; rel=\"first\", \
4526 <https://api.github.com/u?page=9>; rel=\"last\", \
4527 <https://api.github.com/u?page=4>; rel=\"next\"";
4528 assert_eq!(
4529 parse_link_next(not_first).map(|u| u.to_string()),
4530 Some("https://api.github.com/u?page=4".to_string())
4531 );
4532
4533 let unquoted = "<https://api.github.com/u?page=1>; rel=prev, \
4534 <https://api.github.com/u?page=3>; rel=next";
4535 assert_eq!(
4536 parse_link_next(unquoted).map(|u| u.to_string()),
4537 Some("https://api.github.com/u?page=3".to_string())
4538 );
4539
4540 assert!(
4541 parse_link_next("<https://api.github.com/u?page=2; rel=\"next\"").is_none(),
4542 "an unterminated target is not a link-value"
4543 );
4544 }
4545
4546 /// The free cross-check that would have caught the comma bug on its own.
4547 #[test]
4548 fn a_short_collection_is_measured_against_the_count_github_reported() {
4549 assert_eq!(under_collected(1, Some(2)), Some(2), "page 2 was dropped");
4550 assert_eq!(under_collected(2, Some(2)), None, "complete");
4551 assert_eq!(
4552 under_collected(3, Some(2)),
4553 None,
4554 "a collection that grew between pages is not an under-collection"
4555 );
4556 assert_eq!(under_collected(0, None), None, "no count, no claim");
4557 }
4558
4559 // -- redaction ----------------------------------------------------------
4560
4561 #[test]
4562 fn no_type_in_this_crate_renders_a_secret_through_debug() {
4563 let token = UserAccessToken::new(SecretString::from(FIXTURE_TOKEN));
4564 let rendered = format!("{token:?}");
4565 assert!(!rendered.contains(FIXTURE_TOKEN), "{rendered}");
4566 assert!(rendered.contains("[REDACTED]"));
4567 assert!(
4568 rendered.contains("ghu_"),
4569 "the family prefix is diagnostic and is not the secret"
4570 );
4571
4572 let request = ApiRequest::post_json("/x", &json!({"encoded_jit_config": "SECRETBLOB"}))
4573 .expect("serializes");
4574 let rendered = format!("{request:?}");
4575 assert!(!rendered.contains("SECRETBLOB"), "{rendered}");
4576
4577 let response = ApiResponse {
4578 status: StatusCode::OK,
4579 headers: HeaderMap::new(),
4580 body: b"{\"encoded_jit_config\":\"SECRETBLOB\"}".to_vec(),
4581 };
4582 let rendered = format!("{response:?}");
4583 assert!(!rendered.contains("SECRETBLOB"), "{rendered}");
4584 }
4585
4586 // The Definition of Done's log scan is `tests/no_secret_reaches_the_logs.rs`
4587 // and not a unit test here. See the note at the end of `mod testing` for the
4588 // `tracing` callsite-cache reason it cannot be one.
4589
4590 // -- the crate-shape scans ----------------------------------------------
4591 //
4592 // The three gates below share these helpers on purpose. The previous round
4593 // defined `normalise` twice — once in the scan and once in the meta-test
4594 // that checks it — which left the meta-test structurally unable to notice a
4595 // change to the real one. One definition, used by both, is the only shape
4596 // in which a meta-test proves anything.
4597
4598 /// Spelled in halves so that this file's own source does not trip the scan
4599 /// it runs: normalising `concat!("refresh", "token")` leaves the
4600 /// quote-comma-quote between the halves, so no needle ever appears whole.
4601 // The renewal guard that used to live here is gone, and its absence is the
4602 // point. It forbade this crate from naming a refresh token at all, on the
4603 // reasoning that the published App opts out of user-token expiration "so
4604 // GitHub issues nothing to renew". That reasoning rested on a second claim
4605 // -- that renewing needs a confidential client credential -- which GitHub's
4606 // own documentation contradicts for the device flow, and which was then
4607 // disproved against live GitHub: a refresh exchange with `client_id` alone
4608 // answers `200`.
4609 //
4610 // What the guard below still forbids is the part that was always true and
4611 // is the reason renewal is safe here: no confidential credential in this
4612 // crate. Renewal was added *without* one, so the remaining half of this
4613 // scan is now evidence for the design rather than against it.
4614 const CONFIDENTIAL: &[&str] = &[concat!("client", "secret"), concat!("app", "secret")];
4615
4616 const MANIFEST: (&str, &str) = ("Cargo.toml", include_str!("../Cargo.toml"));
4617
4618 /// Every `.rs` file at or below `src/`, named by its `/`-joined path
4619 /// relative to `src/` — so a nested module is `("rest/runners.rs",
4620 /// include_str!("rest/runners.rs"))`, not just its file name.
4621 ///
4622 /// This list used to *be* the claim "every source file in the crate", and a
4623 /// hard-coded list is not that claim — it is a snapshot of it. `c3` and `c4`
4624 /// are the tasks that will add files to this directory, so the list was
4625 /// guaranteed to go stale on exactly the work that most needed scanning: a
4626 /// new `pagination.rs` holding a confidential credential passed silently.
4627 /// [`the_confidential_credential_scan_covers_every_source_file`] pins this
4628 /// by walking the directory tree, so adding a file — at the top level or in
4629 /// a subdirectory — and not adding it here fails.
4630 const CRATE_SOURCES: &[(&str, &str)] = &[
4631 ("demand.rs", include_str!("demand.rs")),
4632 ("device_flow.rs", include_str!("device_flow.rs")),
4633 ("jit.rs", include_str!("jit.rs")),
4634 ("lib.rs", include_str!("lib.rs")),
4635 ("rest.rs", include_str!("rest.rs")),
4636 ];
4637
4638 /// The two source files `c2` owns, plus the manifest. The renewal half of
4639 /// the scan stays inside this boundary; see the scan's own documentation.
4640 const SOURCES_OWNED_BY_C2: &[(&str, &str)] = &[
4641 ("device_flow.rs", include_str!("device_flow.rs")),
4642 ("lib.rs", include_str!("lib.rs")),
4643 MANIFEST,
4644 ];
4645
4646 /// Lower-cased with `_` removed, so that one needle catches the snake,
4647 /// camel, Pascal and screaming-snake spellings of an identifier at once.
4648 /// (Those four spellings cannot be written out here: they are exactly what
4649 /// the gate forbids, which is the constraint on documentation this scan
4650 /// imposes and defends below.)
4651 ///
4652 /// # Why `-` is *not* stripped from Rust source
4653 ///
4654 /// It used to be, and that rejected ordinary English. `c3`'s own file opens
4655 /// with a line stating that the gateway holds no such credential, written
4656 /// with the compound adjective English requires — and stripping `-` turned
4657 /// that sentence into the needle, so the gate accused `c3` of naming a
4658 /// confidential credential in the very line that says it holds none. A
4659 /// compound adjective is not an evasion; it is how the language works, and
4660 /// this brief, this crate's documentation and that line all use one.
4661 ///
4662 /// Nothing is lost, because **a Rust identifier cannot contain `-`**.
4663 /// Stripping it never bought identifier coverage: every casing an identifier
4664 /// can actually take is `_`-separated or unseparated, and all of those still
4665 /// collapse onto the needle. What it bought was coverage of a *kebab-case
4666 /// string literal*, and the residual gap is stated plainly rather than
4667 /// papered over: a `.rs` file that wrote this credential's name as a
4668 /// hyphenated string would not be caught here. That gap is narrow on
4669 /// purpose — OAuth 2.0 and GitHub both spell the field `_`-separated, which
4670 /// this catches — and it is the price of a gate that ordinary prose can
4671 /// coexist with. A gate that fires on correct English is not a stricter
4672 /// gate; it is a gate that gets deleted.
4673 ///
4674 /// The alternatives were weighed. Requiring identifier context needs a Rust
4675 /// lexer to tell `a client-secret-free design` from a TOML key, and gets the
4676 /// wrong answer for both string literals and comments. Excluding comment
4677 /// text needs the same lexer to avoid mangling `//` inside a string, and
4678 /// would stop the gate catching a `TODO` comment proposing to read the
4679 /// credential from the environment — which is precisely the drift worth
4680 /// catching early, while it is still a comment. Stripping one character
4681 /// fewer needs neither, which is why it wins.
4682 ///
4683 /// A space is not stripped either, and for the same reason: it is what lets
4684 /// this crate's prose discuss a "client secret" as two words.
4685 fn normalise_source(source: &str) -> String {
4686 source.to_ascii_lowercase().replace('_', "")
4687 }
4688
4689 /// The manifest keeps `-` stripped: TOML keys and crate names are kebab-case
4690 /// by convention, so `-` there is a word separator rather than a hyphen, and
4691 /// a manifest carries no hyphenated English for it to break.
4692 fn normalise_manifest(manifest: &str) -> String {
4693 manifest.to_ascii_lowercase().replace(['_', '-'], "")
4694 }
4695
4696 /// Which normaliser a scanned file gets. The manifest is the only file whose
4697 /// `-` is a separator rather than punctuation.
4698 fn normalise(name: &str, contents: &str) -> String {
4699 if name == MANIFEST.0 {
4700 normalise_manifest(contents)
4701 } else {
4702 normalise_source(contents)
4703 }
4704 }
4705
4706 /// The part of a source file that is not test code.
4707 ///
4708 /// The boundary is the first line that is **exactly** `#[cfg(test)]`, and
4709 /// the word "exactly" is the fix. Splitting on that literal wherever it
4710 /// appeared also split on it in *prose*, and `lib.rs` has carried such a
4711 /// mention since the `testing` module was documented — so the scan below
4712 /// already stopped nine lines early, today, with nothing to say so. A file
4713 /// whose module documentation happened to mention an inline test module
4714 /// would have had its scanned region truncated to a few dozen lines, after
4715 /// which a real `std::fs::write` in non-test code passed silently. That is
4716 /// the same class of defect as the log scan that captured only its own
4717 /// events and the credential scan that claimed a scope it did not have: a
4718 /// gate whose description outran what it did.
4719 fn non_test_prefix(source: &str) -> &str {
4720 let mut offset = 0;
4721 for line in source.split_inclusive('\n') {
4722 if line.trim() == "#[cfg(test)]" {
4723 return &source[..offset];
4724 }
4725 offset += line.len();
4726 }
4727 source
4728 }
4729
4730 /// The Definition of Done's second item, made checkable rather than
4731 /// reviewed: "no renewal token code path exists, and no client secret
4732 /// appears anywhere in the crate **or its configuration**".
4733 ///
4734 /// # Normalised, because a literal scan is evaded by naming
4735 ///
4736 /// This used to be a case-sensitive `contains` over two snake-case
4737 /// spellings, which is a gate that any ordinary Rust or JSON identifier
4738 /// walks straight through: the camel-cased, Pascal-cased and
4739 /// screaming-snake spellings of the very same two identifiers were all
4740 /// invisible to it. None of those is exotic — several are what the
4741 /// surrounding ecosystem actually calls these fields — so evading this gate
4742 /// never had to be deliberate. See [`normalise_source`] for what is
4743 /// collapsed, what is deliberately not, and why.
4744 ///
4745 /// The consequence is that this crate's *prose* may not write those
4746 /// identifiers either, in any casing: it says "renewal token" and "client
4747 /// secret" as separate words, which normalisation preserves and the scan
4748 /// therefore ignores. That is a real constraint on the documentation, and it
4749 /// is the right way round — a gate loosened until the comments compile is
4750 /// not a gate. It is a constraint on *identifier spellings*, though, and
4751 /// never on English: hyphenating a compound adjective is not writing an
4752 /// identifier, and a gate that could not tell those apart is what this round
4753 /// fixed.
4754 ///
4755 /// # Two different scopes, for two different reasons
4756 ///
4757 /// The **renewal** half stays scoped to the two files `c2` owns plus the
4758 /// manifest. A renewal path in `c3`'s or `c4`'s file would be their finding;
4759 /// failing here on their work would be this task reaching across an
4760 /// ownership boundary.
4761 ///
4762 /// The **client secret** half covers every source file in the crate. That is
4763 /// not a boundary crossing but the opposite: a public client cannot hold a
4764 /// client secret at all (D3, `07-security.md`), so one appearing *anywhere*
4765 /// in this crate is a product defect rather than a matter of whose file it
4766 /// is, and `c2` is the designated owner of that clause. "Every source file"
4767 /// is a claim about the directory, so it is checked against the directory —
4768 /// see [`the_confidential_credential_scan_covers_every_source_file`].
4769 #[test]
4770 fn no_confidential_credential_in_this_crate() {
4771 for &(name, source) in CRATE_SOURCES.iter().chain(std::iter::once(&MANIFEST)) {
4772 let haystack = normalise(name, source);
4773 for forbidden in CONFIDENTIAL {
4774 assert!(
4775 !haystack.contains(forbidden),
4776 "{name} names {forbidden:?} in some spelling: a public client cannot \
4777 secure a confidential credential, and this design never tries to (D3)"
4778 );
4779 }
4780 }
4781 }
4782
4783 /// "Every source file in the crate" is a claim about a directory, and the
4784 /// scan above states it as a hard-coded list. A list is a snapshot: the
4785 /// moment `c3` or `c4` adds a file to `src/`, the claim is false and nothing
4786 /// says so. A `src/pagination.rs` holding a confidential credential passed
4787 /// the gate that exists to catch exactly that.
4788 ///
4789 /// Reading the directory here is what turns the claim back into a claim. It
4790 /// cannot be done in the scan itself — `include_str!` needs a literal path
4791 /// at compile time — so the list stays, and this pins it.
4792 ///
4793 /// # Why it walks the tree instead of listing one directory
4794 ///
4795 /// It used to call `read_dir("src")` once and keep the entries ending in
4796 /// `.rs`. That reads like a directory scan and is not one: a subdirectory
4797 /// module — `src/rest/mod.rs`, `src/rest/runners.rs` — arrives as the single
4798 /// entry `rest`, which does not end in `.rs`, so the filter dropped it and
4799 /// took the files underneath with it. The pin went on passing while those
4800 /// files were scanned by nothing at all.
4801 ///
4802 /// That defeated the pin in exactly the case it was written for. `c3` is the
4803 /// REST inventory gateway, a module directory is the ordinary Rust shape for
4804 /// it, and the failure is silent on both sides: the credential scan does not
4805 /// read the file, and the test whose whole job is to notice that reports
4806 /// success.
4807 ///
4808 /// Recursing is the fix, rather than asserting that `src/` holds no
4809 /// subdirectories. The claim being pinned is about *files*, not about
4810 /// layout; banning the directory would fail `c3` for choosing a normal
4811 /// module shape, and a gate that fails correct work is a gate the next round
4812 /// loosens to get its own work compiling — which is how the normalisation
4813 /// half of this same scan was weakened once already.
4814 ///
4815 /// Names are `/`-joined paths relative to `src/`, which is what
4816 /// `include_str!` takes on every platform, so a nested file is listed as
4817 /// `("rest/runners.rs", include_str!("rest/runners.rs"))` and the two sides
4818 /// compare directly.
4819 #[test]
4820 fn the_confidential_credential_scan_covers_every_source_file() {
4821 // Every `.rs` file at or below `dir`, named by its `/`-joined path
4822 // relative to `src/`.
4823 //
4824 // `file_type()` is deliberately not followed through symlinks: a link
4825 // cannot walk this into a cycle, and a symlinked `.rs` file still lands
4826 // in the list through the extension test. A directory is recursed into
4827 // before the extension is considered, so a directory named `foo.rs`
4828 // is walked rather than mistaken for a file.
4829 fn collect(dir: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
4830 for entry in std::fs::read_dir(dir).expect("the source directory is readable") {
4831 let entry = entry.expect("a readable directory entry");
4832 let name = entry.file_name().to_string_lossy().into_owned();
4833 let relative = if prefix.is_empty() {
4834 name.clone()
4835 } else {
4836 format!("{prefix}/{name}")
4837 };
4838 if entry.file_type().expect("a readable entry type").is_dir() {
4839 collect(&entry.path(), &relative, found);
4840 } else if name.ends_with(".rs") {
4841 found.push(relative);
4842 }
4843 }
4844 }
4845
4846 let mut on_disk = Vec::new();
4847 collect(
4848 std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
4849 "",
4850 &mut on_disk,
4851 );
4852 on_disk.sort();
4853
4854 // Sorted, rather than taken in declaration order. Comparing a sorted
4855 // `on_disk` against an unsorted `scanned` made this assertion depend on
4856 // `CRATE_SOURCES` happening to be declared alphabetically. It is — but
4857 // nothing said so, and the failure that would follow from reordering the
4858 // list is a diff of two lists holding the same names, which reads as a
4859 // coverage gap rather than as the ordering nit it would actually be.
4860 let mut scanned: Vec<String> = CRATE_SOURCES
4861 .iter()
4862 .map(|(name, _)| (*name).to_string())
4863 .collect();
4864 scanned.sort();
4865
4866 assert_eq!(
4867 scanned, on_disk,
4868 "`src/` and the scanned list have diverged. Add the new file to `CRATE_SOURCES` \
4869 with an `include_str!`, naming it by its `/`-joined path relative to `src/`; \
4870 leaving it out means the confidential-credential scan silently stops covering \
4871 `every source file in the crate`, which is the claim it makes."
4872 );
4873 }
4874
4875 /// The scan above, shown to actually catch the spellings it claims to — and
4876 /// to leave alone the ones it claims to leave alone.
4877 ///
4878 /// Without this, "the gate is case-insensitive now" is a comment rather than
4879 /// a fact, and the finding that produced it was precisely a gate whose
4880 /// description outran what it did. It calls [`normalise`], the same function
4881 /// the scan calls, because a meta-test with its own private copy of the
4882 /// thing under test cannot detect a change to it.
4883 #[test]
4884 fn the_confidential_credential_scan_is_not_evaded_by_naming() {
4885 // Assembled at run time rather than written out, for the same reason the
4886 // needles are spelled in halves: a test that contained these spellings
4887 // literally would fail the scan it is checking.
4888 let source_evasions = [
4889 format!("let {}Token = fetch()", "refresh"),
4890 format!("struct {}Token;", "Refresh"),
4891 format!("{}_TOKEN", "REFRESH"),
4892 format!("{}Secret", "client"),
4893 format!("{}_SECRET", "CLIENT"),
4894 format!("{}_secret", "app"),
4895 ];
4896 for evasion in &source_evasions {
4897 let normalised = normalise("lib.rs", evasion);
4898 assert!(
4899 normalised.contains(concat!("refresh", "token"))
4900 || normalised.contains(concat!("client", "secret"))
4901 || normalised.contains(concat!("app", "secret")),
4902 "{evasion:?} would walk straight through the scan"
4903 );
4904 }
4905
4906 // The manifest is where kebab-case is a word separator rather than a
4907 // hyphen, so that is where it is still collapsed.
4908 let manifest_evasion = format!("{}-secret = \"...\"", "client");
4909 assert!(
4910 normalise(MANIFEST.0, &manifest_evasion).contains(concat!("client", "secret")),
4911 "a kebab-case TOML key is an identifier, and the manifest normaliser must \
4912 still collapse it"
4913 );
4914
4915 // And the prose the crate legitimately writes must still pass, or the
4916 // gate would be unusable and would be weakened again to make it usable.
4917 for allowed in [
4918 "a public client cannot hold a client secret",
4919 "the published App issues no renewal token",
4920 // The line that fails the old normalisation, quoted from `c3`'s own
4921 // file. It says the *opposite* of what the gate accused it of.
4922 "//! This gateway is deliberately client-secret-free, as D3 requires.",
4923 // The same shape, for the renewal half.
4924 "a refresh-free credential model",
4925 ] {
4926 let normalised = normalise("lib.rs", allowed);
4927 assert!(
4928 !normalised.contains(concat!("client", "secret"))
4929 && !normalised.contains(concat!("refresh", "token")),
4930 "{allowed:?} is English, not an identifier, and must not trip the scan"
4931 );
4932 }
4933 }
4934
4935 /// The storage boundary, made checkable the same way. `c2` returns the token
4936 /// and never persists it; the machine-scoped store is `d2` and the wiring is
4937 /// `f1`. A dependency on `runner-manager-platform`, or a filesystem write,
4938 /// would silently move that boundary.
4939 #[test]
4940 fn this_crate_persists_nothing_and_does_not_depend_on_the_platform_crate() {
4941 assert!(
4942 !MANIFEST.1.contains("runner-manager-platform"),
4943 "the gateway must be testable with no platform dependency at all"
4944 );
4945
4946 for &(name, source) in SOURCES_OWNED_BY_C2 {
4947 if name == MANIFEST.0 {
4948 continue;
4949 }
4950 // Everything below `#[cfg(test)]` is test code; the boundary is about
4951 // non-test code, and the tests above legitimately read this file.
4952 let non_test = non_test_prefix(source);
4953 // `OpenOptions`, `File::options` and `std::io::Write` are on this
4954 // list because the original four named only the *obvious* ways to
4955 // write a file. A store built with `OpenOptions::new().create(true)`
4956 // would have moved the persistence boundary silently, which is the
4957 // one thing this scan exists to prevent.
4958 for forbidden in [
4959 "std::fs",
4960 "fs::write",
4961 "File::create",
4962 "File::options",
4963 "OpenOptions",
4964 "std::io::Write",
4965 "tokio::fs",
4966 ] {
4967 assert!(
4968 !non_test.contains(forbidden),
4969 "{name} performs a filesystem operation ({forbidden:?}) outside its tests"
4970 );
4971 }
4972 }
4973 }
4974
4975 /// The scan above, shown to be looking at what it says it is looking at.
4976 ///
4977 /// `split("#[cfg(test)]")` matched that literal **anywhere**, prose
4978 /// included. One ordinary sentence in a module's documentation truncated the
4979 /// scanned region to whatever preceded it, and every filesystem call after
4980 /// that point became invisible — with the scan still reporting `ok`. This is
4981 /// the third gate in this crate found describing more than it did, so it
4982 /// gets the same treatment as the other two: a synthetic file where the
4983 /// difference is decisive, and an assertion about the real ones.
4984 #[test]
4985 fn the_non_test_boundary_is_a_line_and_not_a_mention() {
4986 // A file shaped like this crate's own: prose that names the attribute,
4987 // then real non-test code, then the actual module.
4988 let file = "//! Test helpers live in an inline #[cfg(test)] module near the bottom.\n\
4989 \n\
4990 fn persist() { std::fs::write(\"x\", b\"y\").unwrap(); }\n\
4991 \n\
4992 #[cfg(test)]\n\
4993 mod tests {\n\
4994 fn helper() { std::fs::write(\"ok-in-tests\", b\"\").unwrap(); }\n\
4995 }\n";
4996
4997 let non_test = non_test_prefix(file);
4998 assert!(
4999 non_test.contains("fn persist"),
5000 "a prose mention of the attribute truncated the scanned region, and every \
5001 filesystem call below it stopped being scanned — silently:\n{non_test}"
5002 );
5003 assert!(
5004 !non_test.contains("ok-in-tests"),
5005 "the boundary must still exclude the real test module:\n{non_test}"
5006 );
5007
5008 // And on the real files, whose module documentation contains such a
5009 // mention today. `lib.rs` has carried one since `mod testing` was
5010 // written, so this crate was shipping the truncated scan.
5011 for &(name, source) in SOURCES_OWNED_BY_C2 {
5012 if name == MANIFEST.0 {
5013 continue;
5014 }
5015 let expected = source
5016 .lines()
5017 .position(|line| line.trim() == "#[cfg(test)]")
5018 .expect("each source file has an inline test module");
5019 let scanned = non_test_prefix(source).lines().count();
5020 assert_eq!(
5021 scanned, expected,
5022 "{name}: the scanned region ends at line {scanned} but the test module starts \
5023 at line {expected}. The gap is code that claims to be scanned and is not."
5024 );
5025 }
5026 }
5027
5028 #[derive(Debug)]
5029 struct SpyRenewal {
5030 invocations: std::sync::atomic::AtomicUsize,
5031 }
5032 #[async_trait::async_trait]
5033 impl CredentialRenewal for SpyRenewal {
5034 async fn renew(
5035 &self,
5036 _refresh_token: &SecretString,
5037 ) -> Result<UserAccessToken, RenewalError> {
5038 self.invocations
5039 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5040 Ok(UserAccessToken::new(SecretString::from("ghu_renewed")))
5041 }
5042 }
5043
5044 #[tokio::test]
5045 async fn reload_happens_before_renew_to_prevent_cross_process_races() {
5046 let server = MockServer::start().await;
5047 Mock::given(method("GET"))
5048 .and(path("/repos/acme/app"))
5049 .and(header("authorization", "Bearer ghu_initial"))
5050 .respond_with(ResponseTemplate::new(401))
5051 .expect(1)
5052 .mount(&server)
5053 .await;
5054 Mock::given(method("GET"))
5055 .and(path("/repos/acme/app"))
5056 .and(header("authorization", "Bearer ghu_reloaded"))
5057 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
5058 .expect(1)
5059 .mount(&server)
5060 .await;
5061
5062 let renewal = Arc::new(SpyRenewal {
5063 invocations: std::sync::atomic::AtomicUsize::new(0),
5064 });
5065
5066 let document = serde_json::json!({
5067 "access_token": "ghu_initial",
5068 "refresh_token": "ghr_initial",
5069 });
5070 let initial_token =
5071 UserAccessToken::from_stored_document(&SecretString::from(document.to_string()));
5072
5073 let client = AuthenticatedClient::new(
5074 Endpoints::for_test_server(&server.uri()).unwrap(),
5075 initial_token,
5076 Arc::new(TestClock::default()),
5077 )
5078 .unwrap()
5079 .with_credential_source(Arc::new(StoreHolding(Some("ghu_reloaded"))))
5080 .with_renewal(renewal.clone());
5081
5082 let response = client
5083 .get_json::<serde_json::Value>("/repos/acme/app")
5084 .await
5085 .expect("the reloaded token should succeed");
5086 assert_eq!(response["id"], 1);
5087 assert_eq!(
5088 renewal
5089 .invocations
5090 .load(std::sync::atomic::Ordering::SeqCst),
5091 0,
5092 "renew should not be called because reload succeeded"
5093 );
5094 }
5095
5096 #[tokio::test]
5097 async fn proactive_renewal_waits_until_the_access_token_is_near_expiry() {
5098 let renewal = Arc::new(SpyRenewal {
5099 invocations: std::sync::atomic::AtomicUsize::new(0),
5100 });
5101 let document = serde_json::json!({
5102 "access_token": "ghu_initial",
5103 "refresh_token": "ghr_initial",
5104 "access_expires_at": "2026-08-21T02:00:00Z",
5105 "refresh_expires_at": "2027-02-21T00:00:00Z"
5106 });
5107 let clock = Arc::new(TestClock::default());
5108 let client = AuthenticatedClient::new(
5109 Endpoints::production(),
5110 UserAccessToken::from_stored_document(&SecretString::from(document.to_string())),
5111 clock.clone(),
5112 )
5113 .unwrap()
5114 .with_renewal(renewal.clone());
5115
5116 assert!(
5117 !client
5118 .renew_if_expiring_within(Duration::from_secs(30 * 60))
5119 .await
5120 );
5121 assert_eq!(
5122 renewal
5123 .invocations
5124 .load(std::sync::atomic::Ordering::SeqCst),
5125 0
5126 );
5127
5128 clock.advance_secs(91 * 60);
5129 assert!(
5130 client
5131 .renew_if_expiring_within(Duration::from_secs(30 * 60))
5132 .await
5133 );
5134 assert_eq!(
5135 renewal
5136 .invocations
5137 .load(std::sync::atomic::Ordering::SeqCst),
5138 1
5139 );
5140 }
5141
5142 #[tokio::test]
5143 async fn proactive_renewal_repairs_a_pair_with_no_expiry_metadata() {
5144 let renewal = Arc::new(SpyRenewal {
5145 invocations: std::sync::atomic::AtomicUsize::new(0),
5146 });
5147 let document = serde_json::json!({
5148 "access_token": "ghu_initial",
5149 "refresh_token": "ghr_initial"
5150 });
5151 let client = AuthenticatedClient::new(
5152 Endpoints::production(),
5153 UserAccessToken::from_stored_document(&SecretString::from(document.to_string())),
5154 Arc::new(TestClock::default()),
5155 )
5156 .unwrap()
5157 .with_renewal(renewal.clone());
5158
5159 assert!(
5160 client
5161 .renew_if_expiring_within(Duration::from_secs(30 * 60))
5162 .await
5163 );
5164 assert_eq!(
5165 renewal
5166 .invocations
5167 .load(std::sync::atomic::Ordering::SeqCst),
5168 1
5169 );
5170 }
5171}