Skip to main content

deps_core/
github.rs

1//! Shared GitHub tags-API client, used by every ecosystem that resolves package versions
2//! from GitHub repository tags rather than a dedicated package registry (`deps-swift`,
3//! `deps-github-actions`).
4//!
5//! Each ecosystem crate layers its own extras on top of [`GithubTagsClient`] —
6//! `deps-swift` adds GitHub Releases publish-date enrichment, `deps-github-actions` adds a
7//! rate-limit cooldown gate, in-flight request coalescing, and a tag<->SHA cross-reference.
8//! Only the pieces that were byte-for-byte identical between the two — constants,
9//! owner/repo validation, auth-header setup, the tags-pagination loop, and page parsing —
10//! live here, so the two crates cannot silently diverge on this shared behavior (#472).
11
12use crate::error::{DepsError, RateLimitEvidence, Result};
13use crate::freshness::PublishTime;
14use crate::lsp_helpers::{is_dot_segment, warn_rejected_value};
15use bytes::Bytes;
16use dashmap::DashMap;
17use reqwest::header::{AUTHORIZATION, HeaderName};
18use serde::Deserialize;
19use std::collections::HashMap;
20use std::future::Future;
21use std::sync::Arc;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::time::{Duration, Instant};
24
25use crate::EcosystemId;
26use crate::cache::HttpCache;
27
28/// Base URL for the GitHub REST API.
29pub const GITHUB_API: &str = "https://api.github.com";
30
31/// Maximum number of `tags` pages fetched per repository (100 tags/page).
32///
33/// This is a **safety ceiling, not the correctness mechanism** — the loop already stops as
34/// soon as a page comes back with fewer than 100 entries ([`page_has_more`]), which is
35/// GitHub's documented signal that no further page exists. Every real repository
36/// terminates via that signal well before this bound is reached.
37///
38/// The bound exists only to protect against a pathological repo with an unbounded number
39/// of tags. It must stay high enough that it never truncates a real repository's tag
40/// list, because GitHub returns tags in **lexicographic, not semver, order** — verified
41/// live on `firebase/firebase-ios-sdk` (1131 tags): page 1 is headed by `v8.15.0` (a
42/// `v`-prefixed tag lexicographically outranks unprefixed ones), pages 2-9 are entirely
43/// unrelated subproject tags with zero semver-parseable entries, and the real
44/// `11.x`/`12.x` releases only appear around pages 10-11. A low page cap silently drops
45/// those newer tags out of the result entirely — no amount of sorting the *fetched*
46/// subset fixes that, since the highest real versions were never fetched at all.
47pub const MAX_TAG_PAGES: u32 = 30;
48
49// [`paginate_tags`]'s page-fetch concurrency lives in
50// [`crate::pagination::paginate_pages`]'s internal `CONCURRENCY` constant, which this
51// crate's tags pagination delegates to.
52
53/// Whether `name` matches the `owner/repo` GitHub identifier shape every GitHub-tags-backed
54/// ecosystem accepts.
55///
56/// Accepts `[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+`, with neither segment being exactly `.`/`..`
57/// (see [`is_dot_segment`]).
58///
59/// Shared by each ecosystem's `registry::validate_owner_repo` (a credential-bearing
60/// fetch-URL gate) and its formatter's display-URL gate, so the two predicates cannot drift
61/// out of sync on what counts as a valid identity.
62///
63/// # Examples
64///
65/// ```
66/// use deps_core::github::is_valid_github_identity;
67///
68/// assert!(is_valid_github_identity("actions/checkout"));
69/// assert!(!is_valid_github_identity("not-a-valid-identifier"));
70/// assert!(!is_valid_github_identity("owner/.."));
71/// ```
72#[must_use]
73pub fn is_valid_github_identity(name: &str) -> bool {
74    let Some((owner, repo)) = name.split_once('/') else {
75        return false;
76    };
77    if owner.is_empty() || repo.is_empty() || repo.contains('/') {
78        return false;
79    }
80    let charset_ok = |s: &str| {
81        s.bytes()
82            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
83    };
84    charset_ok(owner) && charset_ok(repo) && !is_dot_segment(owner) && !is_dot_segment(repo)
85}
86
87/// Validates that `name` is a valid `owner/repo` GitHub identifier before it reaches a
88/// `{api_base}/repos/{name}/...` fetch as a bare path segment.
89///
90/// The charset regex alone allows `.`, so a repo half of exactly `..` would otherwise
91/// retarget the request one path segment up (`{api_base}/repos/{owner}/../releases` ->
92/// `{api_base}/repos/releases`, #357) — [`is_valid_github_identity`] closes that gap via
93/// [`is_dot_segment`].
94///
95/// # Errors
96///
97/// Returns [`DepsError::InvalidUri`] when `name` is not a valid `owner/repo` identifier.
98pub fn validate_owner_repo(name: &str) -> Result<()> {
99    if is_valid_github_identity(name) {
100        return Ok(());
101    }
102    if let Some((owner, repo)) = name.split_once('/')
103        && (is_dot_segment(owner) || is_dot_segment(repo))
104    {
105        warn_rejected_value("is_dot_segment", "GitHub owner/repo request URL", name);
106    }
107    Err(DepsError::InvalidUri(format!(
108        "invalid owner/repo format: '{}'",
109        crate::redact::redact_declaration_key(name)
110    )))
111}
112
113/// Message for a *confirmed* rate limit (`verified: RateLimitEvidence::Confirmed`, #1295): the response itself
114/// confirmed exhaustion (`X-RateLimit-Remaining: 0` or `Retry-After` — see
115/// `crate::cache::confirmed_rate_limit_exhaustion`).
116const GITHUB_RATE_LIMIT_MESSAGE_VERIFIED: &str = "GitHub API rate limit exceeded. Set GITHUB_TOKEN to increase the limit \
117     (5000 req/h). Run: export GITHUB_TOKEN=$(gh auth token)";
118
119/// Message for an *inferred* rate limit (`verified: RateLimitEvidence::Inferred`, #1295): a 403 with no token and
120/// no corroborating response evidence — usually the rate limit, but not confirmed. Distinct
121/// text from [`GITHUB_RATE_LIMIT_MESSAGE_VERIFIED`] (critic S2) so a human triaging a real
122/// regression — e.g. via `safe_tracing_summary`'s `"rate-limited-unverified"` label, or this
123/// message reaching a diagnostic — has something to distinguish it by, instead of an
124/// unverified guess rendering byte-identical to a confirmed exhaustion.
125const GITHUB_RATE_LIMIT_MESSAGE_UNVERIFIED: &str = "GitHub API request forbidden (403) with no GITHUB_TOKEN configured. This is usually the \
126     unauthenticated rate limit (60 req/h), but could also be an access-restricted repository \
127     or an abuse-detection false positive. Set GITHUB_TOKEN to rule out the rate limit and \
128     increase the limit (5000 req/h). Run: export GITHUB_TOKEN=$(gh auth token)";
129
130/// The actionable error returned when a request hits GitHub's rate limit.
131///
132/// Either a 403 inferred from status and the absence of a token alone
133/// (`verified: RateLimitEvidence::Inferred`), or one `crate::cache` already confirmed via
134/// response evidence (`verified: RateLimitEvidence::Confirmed`, #1295).
135///
136/// A single `fn(RateLimitEvidence)` (#1480 item 8), not the two separate
137/// `github_rate_limit_error`/`github_rate_limit_error_verified` constructors this replaced:
138/// the only difference between the two was which message/`verified` pair to use, so every
139/// caller had to duplicate an `if verified { .. } else { .. }` branch — see
140/// [`crate::rate_limit::RateLimitGate::evidence`] for the matching gate-side projection.
141///
142/// `pub`, not `pub(crate)`: needed both by [`classify_tags_fetch_error`] (this crate) and by
143/// `deps_github_actions::registry::GithubActionsRegistry`'s rate-limit-gate short-circuit
144/// (critic S3), which must replay the same verified state a gate was tripped with rather than
145/// always reporting an unverified guess.
146///
147/// # Examples
148///
149/// ```
150/// use deps_core::RateLimitEvidence;
151/// use deps_core::github::github_rate_limit_error;
152///
153/// let err = github_rate_limit_error(RateLimitEvidence::Confirmed);
154/// assert!(err.to_string().contains("GITHUB_TOKEN"));
155/// ```
156#[must_use]
157pub fn github_rate_limit_error(verified: RateLimitEvidence) -> DepsError {
158    let message = match verified {
159        RateLimitEvidence::Confirmed => GITHUB_RATE_LIMIT_MESSAGE_VERIFIED,
160        RateLimitEvidence::Inferred => GITHUB_RATE_LIMIT_MESSAGE_UNVERIFIED,
161    };
162    DepsError::RateLimited {
163        message: message.into(),
164        verified,
165        // No live response at this canned, inference-only construction site (#1295 N1) —
166        // also called from `deps-github-actions`'s gate-replay path, which has no live
167        // response at all either.
168        source_status: None,
169    }
170}
171
172/// Classifies a GitHub tags-API fetch error into the shape every GitHub-tags-backed
173/// ecosystem's `get_versions` returns.
174///
175/// So `deps-swift` and `deps-github-actions` cannot silently diverge on it (#472-style DRY,
176/// surfaced by #1295): an already-verified [`DepsError::RateLimited`] from `crate::cache`'s
177/// registry-neutral header-evidence check (critic S1 — that check has no GitHub-specific
178/// wording, since it runs for every ecosystem) gets GitHub's specific remedy swapped in via
179/// [`github_rate_limit_error`], a 403 with no `has_token` evidence maps to the same
180/// constructor with `RateLimitEvidence::Inferred`, a 404 maps to
181/// [`DepsError::PackageNotFound`], and everything else passes through unchanged.
182///
183/// A caller that needs a side effect when the result is a rate limit (e.g.
184/// `deps-github-actions`'s local cooldown gate) should check the *returned* error's shape
185/// with `matches!(.., DepsError::RateLimited { .. })` rather than re-deriving the 403/404
186/// classification itself.
187///
188/// # Examples
189///
190/// ```
191/// use deps_core::DepsError;
192/// use deps_core::github::classify_tags_fetch_error;
193///
194/// let not_found = DepsError::HttpStatus {
195///     url: "https://api.github.com/repos/owner/repo/tags".into(),
196///     status: 404,
197/// };
198/// let classified = classify_tags_fetch_error(not_found, "owner/repo", "GitHub", true);
199/// assert!(matches!(classified, DepsError::PackageNotFound { .. }));
200/// ```
201#[must_use]
202pub fn classify_tags_fetch_error(
203    e: DepsError,
204    name: &str,
205    registry: &'static str,
206    has_token: bool,
207) -> DepsError {
208    match &e {
209        DepsError::RateLimited {
210            verified: RateLimitEvidence::Confirmed,
211            ..
212        } => github_rate_limit_error(RateLimitEvidence::Confirmed),
213        DepsError::HttpStatus { status: 403, .. } if !has_token => {
214            github_rate_limit_error(RateLimitEvidence::Inferred)
215        }
216        DepsError::HttpStatus { status: 404, .. } => DepsError::PackageNotFound {
217            package: name.to_string().into(),
218            registry,
219        },
220        _ => e,
221    }
222}
223
224/// Shared cache, auth-header, and API-base state for a GitHub-tags-backed registry client.
225///
226/// Callers embed this alongside their own ecosystem-specific state (caches, coalescing
227/// locks, cross-reference indexes) rather than re-deriving token/header handling
228/// themselves.
229#[derive(Clone)]
230pub struct GithubTagsClient {
231    cache: Arc<HttpCache>,
232    auth_headers: Vec<(HeaderName, crate::secret::ApiToken)>,
233    has_token: bool,
234    api_base: String,
235    /// `{api_base}/`, precomputed once so [`Self::fetch_authenticated`] never re-`format!`s
236    /// it per request; the trailing slash is load-bearing (see that method's docs).
237    trusted_origin: String,
238}
239
240impl GithubTagsClient {
241    /// Creates a new client backed by `cache`.
242    ///
243    /// Reads `GITHUB_TOKEN` from the environment for authenticated requests (5000 req/h vs
244    /// 60 req/h unauthenticated).
245    ///
246    /// # Examples
247    ///
248    /// ```
249    /// use deps_core::HttpCache;
250    /// use deps_core::github::GithubTagsClient;
251    /// use std::sync::Arc;
252    ///
253    /// let client = GithubTagsClient::new(Arc::new(HttpCache::new()));
254    /// assert_eq!(client.api_base(), deps_core::github::GITHUB_API);
255    /// ```
256    #[must_use]
257    pub fn new(cache: Arc<HttpCache>) -> Self {
258        let token = crate::secret::token_from_env("GITHUB_TOKEN");
259        let has_token = token.is_some();
260        let auth_headers = token
261            .map(|token| {
262                tracing::info!("GITHUB_TOKEN detected, using authenticated GitHub API requests");
263                vec![(
264                    AUTHORIZATION,
265                    crate::secret::ApiToken::new(format!("Bearer {}", *token)),
266                )]
267            })
268            .unwrap_or_default();
269
270        Self {
271            cache,
272            auth_headers,
273            has_token,
274            trusted_origin: format!("{GITHUB_API}/"),
275            api_base: GITHUB_API.to_string(),
276        }
277    }
278
279    /// Creates a client with `has_token`/`api_base` set directly, bypassing the
280    /// environment.
281    ///
282    /// For `mockito`-backed tests that need deterministic behavior regardless of the
283    /// ambient `GITHUB_TOKEN` (CI runners often inject one automatically) and a
284    /// request URL pointed at a mock server instead of the real GitHub API.
285    #[cfg(any(test, feature = "test-util"))]
286    #[must_use]
287    pub fn for_test(cache: Arc<HttpCache>, api_base: impl Into<String>, has_token: bool) -> Self {
288        let auth_headers = if has_token {
289            vec![(
290                AUTHORIZATION,
291                crate::secret::ApiToken::new("Bearer test-token".to_string()),
292            )]
293        } else {
294            Vec::new()
295        };
296        let api_base = api_base.into();
297        Self {
298            cache,
299            auth_headers,
300            has_token,
301            trusted_origin: format!("{api_base}/"),
302            api_base,
303        }
304    }
305
306    /// Whether a `GITHUB_TOKEN` was present at construction.
307    #[must_use]
308    pub const fn has_token(&self) -> bool {
309        self.has_token
310    }
311
312    /// The API base URL requests are built against (`GITHUB_API` in production, or a
313    /// mock server URL in tests).
314    #[must_use]
315    pub fn api_base(&self) -> &str {
316        &self.api_base
317    }
318
319    /// Borrowed auth-header pairs to send on each request; empty when no token is set.
320    ///
321    /// `pub(crate)` rather than `pub`: this is the one place [`crate::secret::ApiToken`]'s redaction
322    /// boundary is crossed back into a plain `&str`, so it must not hand the raw token to
323    /// another crate. Ecosystem crates needing an authenticated GitHub request go through
324    /// [`Self::fetch_authenticated`] instead, which applies these headers internally.
325    #[must_use]
326    pub(crate) fn headers(&self) -> Vec<(HeaderName, &str)> {
327        self.auth_headers
328            .iter()
329            .map(|(k, v)| (k.clone(), v.expose_secret()))
330            .collect()
331    }
332
333    /// The shared HTTP cache this client fetches through.
334    ///
335    /// Exposed for ecosystem-specific endpoints beyond the tags API that don't carry a
336    /// credential (e.g. parsing a response this client already fetched) — anything that
337    /// needs this client's `Authorization` header should go through
338    /// [`GithubTagsClient::fetch_authenticated`] instead of rebuilding the header list here.
339    #[must_use]
340    pub const fn cache(&self) -> &Arc<HttpCache> {
341        &self.cache
342    }
343
344    /// Fetches `url` with this client's auth headers, pinning every redirect hop to
345    /// [`Self::api_base`].
346    ///
347    /// The single entry point for an authenticated request against the GitHub API: every
348    /// caller needing this client's `Authorization` header — the tags API
349    /// ([`Self::fetch_tags_page`]), `deps-swift`'s release-dates and search endpoints —
350    /// goes through here rather than combining [`Self::cache`] and `headers()`
351    /// itself, so the origin pin can't be forgotten at a new call site.
352    ///
353    /// Fetches through [`HttpCache::get_cached_trusted_origin_with_headers`] rather than
354    /// [`HttpCache::get_cached_with_headers`], pinning every redirect hop to `api_base` so
355    /// the `Authorization` header can never follow a cross-origin redirect off the GitHub
356    /// API — defense-in-depth alongside reqwest's own default header-stripping on
357    /// cross-origin redirects.
358    ///
359    /// `url` must itself be under `api_base` — this only pins *redirects*, not the initial
360    /// request, so a caller building `url` from a different base (e.g. a hardcoded
361    /// production constant instead of [`Self::api_base`]) both escapes the pin and, in
362    /// tests, silently bypasses the mock server this client was built with.
363    ///
364    /// # Errors
365    ///
366    /// Propagates the underlying HTTP/cache error unchanged.
367    #[tracing::instrument(
368        skip(self),
369        fields(url = %crate::redact::RedactedUrl::new(url))
370    )]
371    pub async fn fetch_authenticated(&self, url: &str) -> Result<Bytes> {
372        self.cache
373            .get_cached_trusted_origin_with_headers(url, &self.trusted_origin, &self.headers())
374            .await
375    }
376
377    /// Fetches one page of the GitHub tags API for `name` (`owner/repo`), authenticated
378    /// with this client's headers.
379    ///
380    /// Centralizes the `{api_base}/repos/{name}/tags?per_page=100&page={page}` URL so
381    /// callers never re-derive it (#472 critic S2) — every caller still supplies its own
382    /// error mapping (rate-limit/not-found translation) via `map_err` on the result.
383    ///
384    /// # Errors
385    ///
386    /// Propagates the underlying HTTP/cache error unchanged.
387    #[tracing::instrument(
388        skip(self),
389        fields(name = %crate::redact::redact_declaration_key(name), page = page)
390    )]
391    pub async fn fetch_tags_page(&self, name: &str, page: u32) -> Result<Bytes> {
392        let url = format!(
393            "{}/repos/{name}/tags?per_page=100&page={page}",
394            self.api_base
395        );
396        self.fetch_authenticated(&url).await
397    }
398}
399
400/// GitHub tags API response item.
401///
402/// Output-only: constructed by this crate's own `serde` deserialization, never by external
403/// code — no constructor is provided.
404#[non_exhaustive]
405#[derive(Debug, Default, Deserialize)]
406pub struct GithubTag {
407    /// The tag name (e.g. `"v1.2.3"`).
408    pub name: String,
409    /// The tagged commit. Defaults when the field is absent so fixtures that omit it (or
410    /// omit `commit.sha` within it) still deserialize — callers that need the SHA (e.g.
411    /// `deps-github-actions`, for SHA-pin resolution) validate it themselves.
412    #[serde(default)]
413    pub commit: GithubTagCommit,
414}
415
416/// The `commit` object nested in a [`GithubTag`].
417///
418/// Output-only: constructed by this crate's own `serde` deserialization, never by external
419/// code — no constructor is provided.
420#[non_exhaustive]
421#[derive(Debug, Default, Deserialize)]
422pub struct GithubTagCommit {
423    /// The full commit SHA the tag points at.
424    #[serde(default)]
425    pub sha: String,
426    /// The commit's API URL (`https://api.github.com/repos/{owner}/{repo}/commits/{sha}`), whose
427    /// `owner/repo` carries the repository's canonical casing. Empty when absent.
428    #[serde(default)]
429    pub url: String,
430}
431
432/// A GitHub `owner/repo` in the canonical casing GitHub reports for the repository.
433///
434/// GitHub resolves repository names case-insensitively, but OSV.dev's `GitHub Actions`
435/// package names are exact-case, so a `uses:` value written in another casing only matches
436/// advisories once mapped to this spelling. Constructible only from a GitHub API URL the
437/// server itself returned, never from user-written text.
438///
439/// # Examples
440///
441/// ```
442/// use deps_core::github::CanonicalRepoName;
443///
444/// let name = CanonicalRepoName::from_commit_url(
445///     "https://api.github.com/repos/Azure/setup-kubectl/commits/0123abcd",
446/// )
447/// .unwrap();
448/// assert_eq!(name.as_str(), "Azure/setup-kubectl");
449/// assert!(CanonicalRepoName::from_commit_url("").is_none());
450/// ```
451#[derive(Debug, Clone, PartialEq, Eq, Hash)]
452pub struct CanonicalRepoName(String);
453
454impl CanonicalRepoName {
455    /// Extracts the repository from a `.../repos/{owner}/{repo}/commits/{sha}` API URL.
456    ///
457    /// Returns `None` when `url` has no such shape or the `owner/repo` fails
458    /// [`validate_owner_repo`].
459    #[must_use]
460    pub fn from_commit_url(url: &str) -> Option<Self> {
461        let (_, tail) = url.split_once("/repos/")?;
462        let mut segments = tail.split('/');
463        let (owner, repo) = (segments.next()?, segments.next()?);
464        if segments.next()? != "commits" {
465            return None;
466        }
467        let name = format!("{owner}/{repo}");
468        validate_owner_repo(&name).ok()?;
469        Some(Self(name))
470    }
471
472    /// The `owner/repo` string in canonical casing.
473    #[must_use]
474    pub fn as_str(&self) -> &str {
475        &self.0
476    }
477}
478
479/// GitHub API error response (rate limit, not found, etc.).
480#[derive(Deserialize)]
481struct GithubErrorResponse {
482    message: String,
483}
484
485/// Returns `true` when a fetched page came back full (`per_page=100` entries), meaning a
486/// subsequent page may exist and should be fetched too. A page with fewer entries is
487/// necessarily the last one.
488pub use crate::pagination::page_has_more;
489
490/// Logs a warning when tag pagination for `name` stops at [`MAX_TAG_PAGES`] while GitHub
491/// still had more pages available (`page_has_more(page_len)`).
492///
493/// A thin, GitHub-specific wrapper over [`crate::pagination::warn_if_pagination_truncated`]
494/// (provider `"GitHub"`, cap [`MAX_TAG_PAGES`]), kept so every call site shares the same
495/// pagination-truncation shape instead of re-deriving it (#472/GitLab-CI-plan §4.4). `ecosystem`
496/// names the caller (e.g. [`EcosystemId::Swift`], [`EcosystemId::GithubActions`]) in the
497/// warning text and tracing field.
498pub fn warn_if_pagination_truncated(
499    ecosystem: EcosystemId,
500    name: &str,
501    page: u32,
502    page_len: usize,
503) {
504    crate::pagination::warn_if_pagination_truncated(
505        "GitHub",
506        ecosystem,
507        "tags",
508        name,
509        page,
510        page_len,
511        MAX_TAG_PAGES,
512    );
513}
514
515/// Drives the GitHub tags pagination loop: page 1 alone, then subsequent pages in batches
516/// of up to `CONCURRENCY` pages.
517///
518/// Page 1 is always fetched by itself before any batching starts, for two reasons: most
519/// repos fit in one page, so this keeps the common case at exactly the one request it took
520/// before this function gained concurrency; and an error on page 1 (bad auth, tripped rate
521/// limit, unknown repo) is now surfaced from a single request instead of fanning a doomed
522/// request out to `CONCURRENCY` pages at once.
523///
524/// Once page 1 is confirmed full, pages 2+ are fetched in batches of `CONCURRENCY`,
525/// stopping once a partial/empty page is seen or [`MAX_TAG_PAGES`] is reached. Pages within
526/// a batch are fetched concurrently, but always processed in page order — the pages
527/// dispatched *after* the batch's partial page are simply discarded once found, not
528/// avoided, since by the time a batch's first result comes back the rest of that batch's
529/// requests are already in flight and cannot be un-sent. This bounds, but does not
530/// eliminate, extra requests: at most `CONCURRENCY - 1` pages beyond a repo's true last page
531/// may be fetched and discarded, only when that last page doesn't land on a batch boundary.
532/// `deps-github-actions`'s tag-to-SHA index dedups "first tag wins" on page order, so
533/// out-of-order processing (not just fetching) would change which tag is picked as
534/// canonical for a shared SHA — hence ordered `buffered`, not `buffer_unordered`.
535///
536/// Returns the tags as [`Paginated`](crate::pagination::Paginated) so callers can tell a
537/// complete list from one cut off at [`MAX_TAG_PAGES`]
538/// ([`ListCoverage::Truncated`](crate::pagination::ListCoverage::Truncated)); only a
539/// `Complete` list proves that a tag or SHA does not exist.
540///
541/// `ecosystem` is forwarded to [`warn_if_pagination_truncated`] to name the caller in the
542/// truncation warning. Extracted so ecosystem crates' tests can inject a fake `fetch_page`
543/// and exercise the real loop — including that warning's call site — without a live
544/// GitHub API.
545///
546/// # Errors
547///
548/// Propagates the first error seen among `fetch_page`'s results (page 1's own error, or the
549/// first in page order within a batch — any other in-flight futures in that batch are
550/// dropped), or the error from [`parse_tags_page`] when a page's body is a GitHub error
551/// object.
552pub async fn paginate_tags<F, Fut>(
553    ecosystem: EcosystemId,
554    name: &str,
555    fetch_page: F,
556) -> Result<crate::pagination::Paginated<GithubTag>>
557where
558    F: FnMut(u32) -> Fut,
559    Fut: Future<Output = Result<Bytes>>,
560{
561    crate::pagination::paginate_pages(
562        "GitHub",
563        ecosystem,
564        "tags",
565        name,
566        MAX_TAG_PAGES,
567        fetch_page,
568        // Not redundant despite clippy's suggestion: `parse_tags_page` takes `&[u8]`, and
569        // `paginate_pages`'s `P` bound is `FnMut(&Bytes) -> _` — passing the bare function
570        // item fails to unify (deref coercion applies inside a closure body, not across a
571        // bare function-item's own argument type).
572        #[allow(clippy::redundant_closure)]
573        |data| parse_tags_page(data),
574    )
575    .await
576}
577
578/// Parses a single GitHub tags API response page into raw tag entries.
579///
580/// GitHub returns an error object instead of an array when rate-limited or on other
581/// errors. Detect this and return a descriptive error; a body that is neither a tags array
582/// nor a recognizable error object is treated as an empty page.
583///
584/// # Errors
585///
586/// Returns [`DepsError::ParseError`] when `data` parses as a GitHub error object.
587pub fn parse_tags_page(data: &[u8]) -> Result<Vec<GithubTag>> {
588    match crate::parser::parse_json_checked(data) {
589        Ok(tags) => Ok(tags),
590        Err(_) => {
591            if let Ok(err) = crate::parser::parse_json_checked::<GithubErrorResponse>(data) {
592                Err(DepsError::parse_error("GitHub tags response", &err.message))
593            } else {
594                Ok(vec![])
595            }
596        }
597    }
598}
599
600/// Strips a leading `v`/`V` tag prefix.
601///
602/// Shared by every ecosystem that joins a GitHub Release's `tag_name` against a
603/// tags-API-derived version — a divergent strip between the two would silently drop a
604/// release's publish date for the affected tag (#223). Both cases are real GitHub tag
605/// conventions (`v2.62.0`, `V2.62.0`); stripping only lowercase `v` would leave `V2.62.0`
606/// unparseable as semver, silently dropping a real, installable tag.
607///
608/// # Examples
609///
610/// ```
611/// use deps_core::github::normalize_tag;
612///
613/// assert_eq!(normalize_tag("v1.2.3"), "1.2.3");
614/// assert_eq!(normalize_tag("V1.2.3"), "1.2.3");
615/// assert_eq!(normalize_tag("1.2.3"), "1.2.3");
616/// ```
617#[must_use]
618pub fn normalize_tag(name: &str) -> &str {
619    name.strip_prefix(['v', 'V']).unwrap_or(name)
620}
621
622/// Converts a raw git-tags-datasource response into a newest-first, semver-filtered list.
623///
624/// Shared by every ecosystem that resolves versions from git tags (GitHub Actions, GitLab
625/// CI, Swift) — `tags` may be accumulated across pages.
626///
627/// `name` extracts the raw tag/release name from each item; `build` receives the item, its
628/// [`normalize_tag`]-stripped name, and the already-parsed [`semver::Version`], and returns
629/// the ecosystem's own version type — or `None` to drop the entry (e.g. GitHub Actions and
630/// GitLab CI drop a tag whose commit SHA is not [`crate::lsp_helpers::is_full_sha`]-shaped,
631/// security S-3; Swift never drops one, since it tracks no SHA at all).
632///
633/// Filtering (`build`) runs **before** deduping by normalized name, first-`build`-success
634/// wins — not the other way around. A prior per-ecosystem implementation deduped first,
635/// which meant a bad-SHA `v1.0.0` (rejected by `build`, so it should never win) could still
636/// consume the dedup slot ahead of a later, SHA-valid `1.0.0` for the same normalized
637/// version, hiding it from the result entirely (issue #1480 item 1).
638///
639/// # Examples
640///
641/// ```
642/// use deps_core::github::{parse_tags_page, semver_tags_newest_first};
643///
644/// let json = br#"[{"name": "v1.0.0"}, {"name": "v2.0.0"}]"#;
645/// let tags = parse_tags_page(json).unwrap();
646/// let versions = semver_tags_newest_first(
647///     tags,
648///     |t| t.name.as_str(),
649///     |tag, _normalized, _parsed| Some(tag.name),
650/// );
651/// assert_eq!(versions, vec!["v2.0.0".to_string(), "v1.0.0".to_string()]);
652/// ```
653pub fn semver_tags_newest_first<S, T>(
654    tags: impl IntoIterator<Item = S>,
655    name: impl Fn(&S) -> &str,
656    mut build: impl FnMut(S, &str, &semver::Version) -> Option<T>,
657) -> Vec<T> {
658    let mut built: Vec<(T, semver::Version, String)> = Vec::new();
659    for tag in tags {
660        let normalized = normalize_tag(name(&tag)).to_string();
661        let Ok(parsed) = semver::Version::parse(&normalized) else {
662            continue;
663        };
664        if let Some(item) = build(tag, &normalized, &parsed) {
665            built.push((item, parsed, normalized));
666        }
667    }
668    let mut seen = std::collections::HashSet::new();
669    built.retain(|(_, _, normalized)| seen.insert(normalized.clone()));
670    built.sort_unstable_by(|a, b| b.1.cmp(&a.1));
671    built.into_iter().map(|(item, _, _)| item).collect()
672}
673
674/// TTL for a successful `/releases` memo entry (§3.1 of #223's plan). Chosen so a newly
675/// published release surfaces within a coffee break while keeping the per-package cost at 4
676/// requests/hour worst case.
677const RELEASE_DATES_TTL: Duration = Duration::from_mins(15);
678
679/// TTL for a memo entry recording a *failed* `/releases` fetch (network error, rate limit,
680/// unparseable body). Deliberately distinct and much shorter than [`RELEASE_DATES_TTL`]:
681/// caching a failure for the full positive TTL would black out a package's dates for 15
682/// minutes after one transient error, while not caching it at all would let a rate-limit
683/// storm or a non-GitHub identity re-fire the request on every keystroke (#223 M5).
684const RELEASE_DATES_ERROR_TTL: Duration = Duration::from_secs(90);
685
686/// Per-request timeout for the `/releases` fetch inside [`ReleaseDatesCache::fetch`].
687///
688/// `fetch` is meant to run concurrently with a tags fetch under `tokio::join!`, which
689/// otherwise has no timeout of its own beyond [`HttpCache`]'s generic client timeout — a
690/// slow or hanging release-dates response must not hold up hover/completion or eat into
691/// their latency budget. Elapsing this timeout is treated the same as any other fetch
692/// failure: an empty map, memoized under [`RELEASE_DATES_ERROR_TTL`], never propagated.
693const RELEASE_DATES_FETCH_TIMEOUT: Duration = Duration::from_secs(2);
694
695/// Maximum number of packages held in a [`ReleaseDatesCache`] at once. Comfortably above the
696/// distinct-package count of any realistic workspace while bounding the memo at a few
697/// hundred KB (#223 M7).
698const MAX_RELEASE_DATES_MEMO_ENTRIES: usize = 256;
699
700/// One memoized `/releases` lookup for a single package.
701///
702/// A release's publish time is immutable once published, so a stale entry can only ever
703/// *lack* a very recent release — never report a wrong date. The TTL therefore governs how
704/// quickly a brand-new release acquires a date, not correctness.
705struct ReleaseDatesEntry {
706    fetched_at: Instant,
707    dates: Arc<HashMap<String, PublishTime>>,
708    /// TTL for *this* entry — [`RELEASE_DATES_TTL`] on success, the much shorter
709    /// [`RELEASE_DATES_ERROR_TTL`] on failure. Carried per entry rather than derived at read
710    /// time so one expiry check covers both outcomes, and an empty-but-successful fetch (a
711    /// repo with genuinely no releases) is never mistaken for a failure (#223 M5).
712    ttl: Duration,
713}
714
715/// Evicts entries from `map` when it is already at [`MAX_RELEASE_DATES_MEMO_ENTRIES`], ahead
716/// of an insert that would otherwise grow it further: first every entry expired against its
717/// own `ttl`, then — only if that freed nothing — the single oldest entry by `fetched_at`.
718/// The O(n) scan runs only on an insert that finds the map full (#223 M7).
719fn evict_release_dates_if_full(map: &DashMap<String, ReleaseDatesEntry>) {
720    crate::cache_policy::evict_expired_then_oldest(
721        map,
722        MAX_RELEASE_DATES_MEMO_ENTRIES,
723        |e| e.fetched_at,
724        |e| e.ttl,
725    );
726}
727
728/// GitHub releases API response item.
729#[derive(Deserialize)]
730struct GithubRelease {
731    tag_name: String,
732    published_at: Option<String>,
733    #[serde(default)]
734    draft: bool,
735}
736
737/// Parses a GitHub `/releases` page into a normalized-tag -> publish-time map.
738///
739/// Returns `None` for malformed JSON, an unexpected shape, or a GitHub error object — a
740/// genuine *parse failure*, distinct from `Some(HashMap::new())`, which means the page
741/// parsed successfully and the repo simply has no (non-draft, dated) releases. The caller
742/// relies on this distinction to memoize a parse failure under the short
743/// [`RELEASE_DATES_ERROR_TTL`] rather than the positive [`RELEASE_DATES_TTL`] (#223 S3) —
744/// release dates are still strictly best-effort overall, since neither case ever propagates
745/// an error out of [`ReleaseDatesCache::fetch`]. Skips draft releases and releases with no
746/// `published_at`; a prerelease is deliberately *kept* — it is still a real, dated release
747/// and its tag should still get a publish date. GitHub returns releases in `created_at`
748/// descending order, so `entry(..).or_insert(..)` keeps the *first* (newest) release seen
749/// for a given normalized tag — the deterministic collision policy for the rare case of two
750/// releases pointing at the same tag (#223 M2).
751fn parse_releases_page(data: &[u8]) -> Option<HashMap<String, PublishTime>> {
752    let releases: Vec<GithubRelease> = crate::parser::parse_json_checked(data).ok()?;
753    let mut dates = HashMap::new();
754    for release in releases {
755        if release.draft {
756            continue;
757        }
758        let Some(published) = release
759            .published_at
760            .as_deref()
761            .and_then(PublishTime::parse_rfc3339)
762        else {
763            continue;
764        };
765        dates
766            .entry(normalize_tag(&release.tag_name).to_string())
767            .or_insert(published);
768    }
769    Some(dates)
770}
771
772/// Classifies a `/releases` fetch outcome into the `(dates, ttl)` pair to memoize.
773///
774/// `None` represents an elapsed [`RELEASE_DATES_FETCH_TIMEOUT`] (the outer
775/// `tokio::time::timeout::Elapsed` collapsed via `.ok()` at the call site, since it carries
776/// no useful data of its own). Every failure path — timeout, HTTP/network error, or a
777/// response that parses as JSON but isn't a valid `/releases` page — gets the short
778/// [`RELEASE_DATES_ERROR_TTL`]; only a successfully parsed page (which may itself be an
779/// empty map, for a repo with no releases) gets the positive [`RELEASE_DATES_TTL`] (#223
780/// S3). Extracted as a pure function so the TTL decision itself — not just the memo's
781/// read-side retention behavior — is directly unit-testable without a live fetch or a real
782/// `Elapsed`.
783fn classify_release_fetch(
784    outcome: Option<Result<Bytes>>,
785) -> (HashMap<String, PublishTime>, Duration) {
786    match outcome {
787        Some(Ok(data)) => match parse_releases_page(&data) {
788            Some(dates) => (dates, RELEASE_DATES_TTL),
789            None => (HashMap::new(), RELEASE_DATES_ERROR_TTL),
790        },
791        Some(Err(_)) | None => (HashMap::new(), RELEASE_DATES_ERROR_TTL),
792    }
793}
794
795/// Memoized, best-effort GitHub Releases publish-date cache.
796///
797/// Shared by every ecosystem that resolves package versions from GitHub tags but wants to
798/// enrich them with a release's `published_at` (`deps-swift`, `deps-github-actions`).
799/// Fetching `/releases` is a *second*, separate request from the tags fetch that produces
800/// the version list itself — [`Self::fetch`] is meant to run concurrently with that tags
801/// fetch (e.g. via `tokio::join!`) and is infallible by construction, so it can never
802/// perturb the tags fetch's error propagation. A caller joins the returned map onto its own
803/// already-fetched, ecosystem-specific version list by [`normalize_tag`]ing each version's
804/// tag text (#223).
805///
806/// One cache may safely be shared across [`Self::fetch`] calls passing different
807/// [`GithubTagsClient`]s (e.g. a caller that swaps in a mock client under test): entries are
808/// keyed on `(client.api_base(), name)`, not `name` alone, so a hit fetched via one origin's
809/// client can never serve a read for another origin (#486 critic M1).
810#[derive(Default)]
811pub struct ReleaseDatesCache {
812    dates: DashMap<String, ReleaseDatesEntry>,
813    /// Set once the first skipped release-date enrichment (no `GITHUB_TOKEN`) has been
814    /// logged, so the informational message fires at most once per cache instance rather
815    /// than once per hover/completion/document-open (#223). A process running more than
816    /// one cache (e.g. `deps-swift` and `deps-github-actions` each own one) logs it once
817    /// per cache, not once globally.
818    enrichment_skip_logged: AtomicBool,
819}
820
821impl ReleaseDatesCache {
822    /// Creates an empty cache.
823    ///
824    /// # Examples
825    ///
826    /// ```
827    /// use deps_core::github::ReleaseDatesCache;
828    ///
829    /// let _cache = ReleaseDatesCache::new();
830    /// ```
831    #[must_use]
832    pub fn new() -> Self {
833        Self::default()
834    }
835
836    /// Fetches the newest ~100 GitHub Releases for `name` (`owner/repo`) via `github`, and
837    /// returns a normalized-tag -> publish-time map, memoized behind a per-package TTL.
838    ///
839    /// Best-effort by construction (#223): a malformed identity or a validation failure
840    /// returns an empty map with **zero requests**; a missing `GITHUB_TOKEN` returns an
841    /// empty map (logged once per cache instance, naming `ecosystem`) with zero requests; any
842    /// live-fetch error (network, rate limit, unparseable body, or exceeding the fetch
843    /// timeout) returns an empty map, memoized under the short error TTL rather than the
844    /// positive success TTL. Never propagates an error — a caller running this under
845    /// `tokio::join!` alongside a tags fetch must not have that fetch perturbed by a
846    /// release-dates failure.
847    ///
848    /// Runs its own [`validate_owner_repo`] guard, independent of any validation the tags
849    /// fetch performs: under a `tokio::join!` that guard may not run first, and this method
850    /// interpolates `name` into an `api.github.com` path of its own (#223 M6).
851    ///
852    /// # Examples
853    ///
854    /// A malformed identity is rejected before any request is built — deterministic and
855    /// network-free, so it doubles as a runnable example:
856    ///
857    /// ```
858    /// use deps_core::EcosystemId;
859    /// use deps_core::HttpCache;
860    /// use deps_core::github::{GithubTagsClient, ReleaseDatesCache};
861    /// use std::sync::Arc;
862    ///
863    /// # #[tokio::main]
864    /// # async fn main() {
865    /// let cache = ReleaseDatesCache::new();
866    /// let github = GithubTagsClient::new(Arc::new(HttpCache::new()));
867    /// let dates = cache
868    ///     .fetch(&github, "not-a-valid-owner-repo", EcosystemId::Swift)
869    ///     .await;
870    /// assert!(dates.is_empty());
871    /// # }
872    /// ```
873    #[tracing::instrument(
874        skip(self, github),
875        fields(name = %crate::redact::redact_declaration_key(name), %ecosystem)
876    )]
877    pub async fn fetch(
878        &self,
879        github: &GithubTagsClient,
880        name: &str,
881        ecosystem: EcosystemId,
882    ) -> Arc<HashMap<String, PublishTime>> {
883        if validate_owner_repo(name).is_err() {
884            return Arc::new(HashMap::new());
885        }
886
887        // Keyed on `(api_base, name)`, not `name` alone: a cache shared across clients
888        // pointed at different origins (mock server, GitHub Enterprise) must not let a hit
889        // from one origin serve another's read (#486 M1). `\0` cannot occur in either half.
890        let key = format!("{}\0{name}", github.api_base());
891
892        let now = Instant::now();
893        if let Some(entry) = self.dates.get(&key)
894            && now.duration_since(entry.fetched_at) < entry.ttl
895        {
896            return Arc::clone(&entry.dates);
897        }
898
899        if !github.has_token() {
900            if !self.enrichment_skip_logged.swap(true, Ordering::Relaxed) {
901                tracing::info!(
902                    "GITHUB_TOKEN not set — {ecosystem} release dates are unavailable; hover \
903                     and completion will omit publish ages. Run: export GITHUB_TOKEN=$(gh auth token)"
904                );
905            }
906            return Arc::new(HashMap::new());
907        }
908
909        let url = format!("{}/repos/{name}/releases?per_page=100", github.api_base());
910        let fetch_result = tokio::time::timeout(
911            RELEASE_DATES_FETCH_TIMEOUT,
912            github.fetch_authenticated(&url),
913        )
914        .await;
915        match &fetch_result {
916            // #756: never interpolate `e`'s `Display` — see `DepsError::safe_tracing_summary`.
917            // Same invariant as `cache.rs`/`osv/mod.rs`, kept uniform even though GitHub auth
918            // flows via header (currently safe) rather than the URL.
919            Ok(Err(e)) => {
920                let (status, cause) = e.safe_tracing_summary();
921                tracing::debug!(
922                    package = %crate::redact::redact_declaration_key(name),
923                    status = ?status,
924                    cause,
925                    "release dates fetch failed"
926                );
927            }
928            Err(_) => tracing::debug!(
929                package = %crate::redact::redact_declaration_key(name),
930                "release dates fetch timed out"
931            ),
932            Ok(Ok(_)) => {}
933        }
934        let (dates, ttl) = classify_release_fetch(fetch_result.ok());
935        let dates = Arc::new(dates);
936
937        // Refreshing an already-present key (the common case: this package's own entry just
938        // expired) doesn't grow the map, so evicting ahead of it would drop an unrelated
939        // live entry for no reason.
940        if !self.dates.contains_key(&key) {
941            evict_release_dates_if_full(&self.dates);
942        }
943        self.dates.insert(
944            key,
945            ReleaseDatesEntry {
946                fetched_at: now,
947                dates: Arc::clone(&dates),
948                ttl,
949            },
950        );
951        dates
952    }
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958    #[cfg(feature = "test-util")]
959    use crate::test_util::{capture_tracing_output, capture_tracing_output_async};
960
961    // --- semver_tags_newest_first ---
962
963    #[test]
964    fn test_semver_tags_newest_first_sorts_descending() {
965        let tags = parse_tags_page(br#"[{"name": "1.0.0"}, {"name": "2.0.0"}]"#).unwrap();
966        let versions = semver_tags_newest_first(tags, |t| t.name.as_str(), |t, _, _| Some(t.name));
967        assert_eq!(versions, vec!["2.0.0".to_string(), "1.0.0".to_string()]);
968    }
969
970    #[test]
971    fn test_semver_tags_newest_first_skips_non_semver() {
972        let tags = parse_tags_page(br#"[{"name": "not-semver"}]"#).unwrap();
973        let versions = semver_tags_newest_first(tags, |t| t.name.as_str(), |t, _, _| Some(t.name));
974        assert!(versions.is_empty());
975    }
976
977    /// #1480 item 1: `build`'s rejection must run *before* the dedupe pass — a bad entry
978    /// (here, `build` returning `None` to simulate a filter like an invalid SHA) must not
979    /// consume the dedup slot ahead of a later, valid entry for the same normalized version.
980    #[test]
981    fn test_semver_tags_newest_first_rejected_entry_does_not_hide_later_valid_one() {
982        let tags = parse_tags_page(br#"[{"name": "v1.0.0"}, {"name": "1.0.0"}]"#).unwrap();
983        let versions = semver_tags_newest_first(
984            tags,
985            |t| t.name.as_str(),
986            |t, _normalized, _parsed| (t.name != "v1.0.0").then_some(t.name),
987        );
988        assert_eq!(versions, vec!["1.0.0".to_string()]);
989    }
990
991    #[test]
992    fn test_semver_tags_newest_first_dedupes_by_normalized_name_first_build_wins() {
993        let tags = parse_tags_page(br#"[{"name": "v1.0.0"}, {"name": "1.0.0"}]"#).unwrap();
994        let versions = semver_tags_newest_first(tags, |t| t.name.as_str(), |t, _, _| Some(t.name));
995        assert_eq!(versions, vec!["v1.0.0".to_string()]);
996    }
997
998    // --- is_valid_github_identity / validate_owner_repo ---
999
1000    #[test]
1001    fn test_is_valid_github_identity_accepts_owner_repo() {
1002        assert!(is_valid_github_identity("actions/checkout"));
1003        assert!(is_valid_github_identity("org.name/repo_name-v2"));
1004    }
1005
1006    #[test]
1007    fn test_is_valid_github_identity_rejects_malformed() {
1008        assert!(!is_valid_github_identity("no-slash"));
1009        assert!(!is_valid_github_identity(""));
1010        assert!(!is_valid_github_identity("owner/repo/extra"));
1011        assert!(!is_valid_github_identity("owner/ repo"));
1012        assert!(!is_valid_github_identity("../../etc/passwd"));
1013    }
1014
1015    #[test]
1016    fn test_is_valid_github_identity_rejects_dot_segment() {
1017        assert!(!is_valid_github_identity("owner/.."));
1018        assert!(!is_valid_github_identity("owner/."));
1019        assert!(!is_valid_github_identity("../repo"));
1020        assert!(!is_valid_github_identity("./repo"));
1021    }
1022
1023    #[test]
1024    fn test_validate_owner_repo_invalid_format_message() {
1025        let err = validate_owner_repo("no-slash").unwrap_err();
1026        assert!(err.to_string().contains("invalid owner/repo format"));
1027    }
1028
1029    #[test]
1030    fn test_validate_owner_repo_valid() {
1031        assert!(validate_owner_repo("apple/swift-nio").is_ok());
1032        assert!(validate_owner_repo("actions/checkout").is_ok());
1033        assert!(validate_owner_repo("org.name/repo_name-v2").is_ok());
1034    }
1035
1036    #[test]
1037    fn test_validate_owner_repo_invalid() {
1038        assert!(validate_owner_repo("no-slash").is_err());
1039        assert!(validate_owner_repo("../../etc/passwd").is_err());
1040        assert!(validate_owner_repo("owner/repo/extra").is_err());
1041        assert!(validate_owner_repo("owner/ repo").is_err());
1042        assert!(validate_owner_repo("").is_err());
1043    }
1044
1045    #[test]
1046    fn test_validate_owner_repo_rejects_dot_segment_repo() {
1047        // Regression for #357: the charset regex alone allows `.`, so a repo half of
1048        // exactly `..` previously passed — retargeting the request one path segment up.
1049        assert!(validate_owner_repo("owner/..").is_err());
1050        assert!(validate_owner_repo("owner/.").is_err());
1051    }
1052
1053    #[test]
1054    fn test_validate_owner_repo_rejects_dot_segment_owner() {
1055        assert!(validate_owner_repo("../repo").is_err());
1056        assert!(validate_owner_repo("./repo").is_err());
1057    }
1058
1059    /// Code-review finding #2 (post-critic-review sweep, #1209): the invalid-format rejection
1060    /// message embedded the raw `name` directly into `DepsError::InvalidUri`'s `Display` text —
1061    /// reachable through the same `error = %e` fields already redacted at the `package` level
1062    /// for every caller this PR instrumented (`deps-swift`, `deps-github-actions`).
1063    #[test]
1064    fn test_validate_owner_repo_invalid_format_message_redacts_credential_shaped_input() {
1065        let err = validate_owner_repo("user:hunter2@evil.example").unwrap_err();
1066        assert!(
1067            !err.to_string().contains("hunter2"),
1068            "error message leaked a credential-shaped owner/repo value: {err}"
1069        );
1070    }
1071
1072    // --- page_has_more / warn_if_pagination_truncated ---
1073
1074    #[test]
1075    fn test_page_has_more_full_page_continues() {
1076        assert!(page_has_more(100));
1077    }
1078
1079    #[test]
1080    fn test_page_has_more_partial_page_stops() {
1081        assert!(!page_has_more(99));
1082        assert!(!page_has_more(0));
1083    }
1084
1085    #[cfg(feature = "test-util")]
1086    #[test]
1087    fn test_pagination_warns_when_truncated_at_cap() {
1088        let output = capture_tracing_output(|| {
1089            warn_if_pagination_truncated(EcosystemId::Swift, "owner/repo", MAX_TAG_PAGES, 100);
1090        });
1091        assert!(output.contains("owner/repo"), "output was: {output}");
1092        assert!(output.contains("swift"), "output was: {output}");
1093        assert!(output.contains("cap"), "output was: {output}");
1094    }
1095
1096    #[cfg(feature = "test-util")]
1097    #[test]
1098    fn test_pagination_silent_when_under_cap() {
1099        let output = capture_tracing_output(|| {
1100            warn_if_pagination_truncated(EcosystemId::Swift, "owner/repo", MAX_TAG_PAGES - 1, 100);
1101        });
1102        assert!(output.is_empty(), "output was: {output}");
1103    }
1104
1105    #[cfg(feature = "test-util")]
1106    #[test]
1107    fn test_pagination_silent_when_last_page_at_cap_is_partial() {
1108        let output = capture_tracing_output(|| {
1109            warn_if_pagination_truncated(EcosystemId::Swift, "owner/repo", MAX_TAG_PAGES, 42);
1110        });
1111        assert!(output.is_empty(), "output was: {output}");
1112    }
1113
1114    /// Builds a JSON tags page with `count` uniquely named entries.
1115    fn tags_page_json(count: usize) -> Bytes {
1116        let entries: Vec<String> = (0..count)
1117            .map(|i| format!(r#"{{"name":"tag{i}"}}"#))
1118            .collect();
1119        Bytes::from(format!("[{}]", entries.join(",")))
1120    }
1121
1122    #[tokio::test]
1123    async fn test_paginate_tags_single_page_repo_fetches_exactly_once() {
1124        use std::sync::atomic::{AtomicU32, Ordering};
1125
1126        let calls = AtomicU32::new(0);
1127        let result = paginate_tags(EcosystemId::Swift, "owner/repo", |page| {
1128            calls.fetch_add(1, Ordering::SeqCst);
1129            async move {
1130                match page {
1131                    1 => Ok(tags_page_json(42)),
1132                    _ => panic!("page {page} must not be fetched: page 1 is fetched alone and is already partial"),
1133                }
1134            }
1135        })
1136        .await
1137        .unwrap();
1138
1139        assert_eq!(
1140            calls.load(Ordering::SeqCst),
1141            1,
1142            "the common single-page-repo case must not pay for batching"
1143        );
1144        assert_eq!(result.items.len(), 42);
1145        assert_eq!(result.coverage, crate::pagination::ListCoverage::Complete);
1146    }
1147
1148    #[cfg(feature = "test-util")]
1149    #[tokio::test]
1150    async fn test_paginate_tags_stops_after_partial_page() {
1151        use std::sync::atomic::{AtomicU32, Ordering};
1152
1153        // Page 2 is the true last page, but it falls inside the first batch dispatched after
1154        // page 1 (pages 2-6, CONCURRENCY=5), so pages 3-6 are already in flight and get
1155        // fetched too, then discarded — the documented bounded overfetch tradeoff of batched
1156        // concurrency. Page 7+ must never be dispatched.
1157        let calls = AtomicU32::new(0);
1158        let mut tags = Vec::new();
1159        let output = capture_tracing_output_async(async {
1160            let result = paginate_tags(EcosystemId::Swift, "owner/repo", |page| {
1161                calls.fetch_add(1, Ordering::SeqCst);
1162                async move {
1163                    match page {
1164                        1 => Ok(tags_page_json(100)),
1165                        2 => Ok(tags_page_json(42)),
1166                        3..=6 => Ok(tags_page_json(0)),
1167                        _ => panic!(
1168                            "page {page} must not be fetched outside the batch containing the partial page"
1169                        ),
1170                    }
1171                }
1172            })
1173            .await
1174            .unwrap();
1175            assert_eq!(result.coverage, crate::pagination::ListCoverage::Complete);
1176            tags = result.items;
1177        })
1178        .await;
1179
1180        assert_eq!(
1181            calls.load(Ordering::SeqCst),
1182            6,
1183            "page 1 fetched alone, then the whole batch containing partial page 2 (2-6)"
1184        );
1185        assert_eq!(
1186            tags.len(),
1187            142,
1188            "only tags through the true last page (2) are kept, despite pages 3-6 being fetched"
1189        );
1190        assert!(
1191            output.is_empty(),
1192            "must not warn below the page cap: {output}"
1193        );
1194    }
1195
1196    #[tokio::test]
1197    async fn test_paginate_tags_preserves_page_order_despite_inverted_completion() {
1198        /// Builds a JSON tags page with `count` entries named `{prefix}-{i}`, so the
1199        /// origin page of each output tag is identifiable.
1200        fn named_page(prefix: &str, count: usize) -> Bytes {
1201            let entries: Vec<String> = (0..count)
1202                .map(|i| format!(r#"{{"name":"{prefix}-{i}"}}"#))
1203                .collect();
1204            Bytes::from(format!("[{}]", entries.join(",")))
1205        }
1206
1207        // Pages 2-5 resolve slowest-first (page 2 waits longest); page 6 resolves instantly.
1208        // If `paginate_tags` used `buffer_unordered` instead of ordered `buffered`, output
1209        // would follow completion order (6,5,4,3,2) instead of page order — `deps-github-
1210        // actions`'s tag-to-SHA "first tag wins" dedup depends on the latter.
1211        let result = paginate_tags(EcosystemId::Swift, "owner/repo", |page| async move {
1212            match page {
1213                1 => Ok(named_page("page1", 100)),
1214                2 => {
1215                    tokio::time::sleep(Duration::from_millis(40)).await;
1216                    Ok(named_page("page2", 100))
1217                }
1218                3 => {
1219                    tokio::time::sleep(Duration::from_millis(30)).await;
1220                    Ok(named_page("page3", 100))
1221                }
1222                4 => {
1223                    tokio::time::sleep(Duration::from_millis(20)).await;
1224                    Ok(named_page("page4", 100))
1225                }
1226                5 => {
1227                    tokio::time::sleep(Duration::from_millis(10)).await;
1228                    Ok(named_page("page5", 100))
1229                }
1230                6 => Ok(named_page("page6", 1)),
1231                _ => panic!("page {page} must not be fetched"),
1232            }
1233        })
1234        .await
1235        .unwrap();
1236
1237        assert_eq!(result.items.len(), 501);
1238        for (expected_prefix, start, len) in [
1239            ("page1", 0, 100),
1240            ("page2", 100, 100),
1241            ("page3", 200, 100),
1242            ("page4", 300, 100),
1243            ("page5", 400, 100),
1244            ("page6", 500, 1),
1245        ] {
1246            for i in 0..len {
1247                assert!(
1248                    result.items[start + i].name.starts_with(expected_prefix),
1249                    "expected {expected_prefix} at index {}, got {}",
1250                    start + i,
1251                    result.items[start + i].name
1252                );
1253            }
1254        }
1255    }
1256
1257    #[tokio::test]
1258    async fn test_paginate_tags_mid_batch_error_propagates_as_that_pages_error() {
1259        // Page 4 errors while pages 5-6 (later in page order, but faster to resolve) are
1260        // still in flight. The error returned must be page 4's own, not silently swapped
1261        // for a sibling's outcome or swallowed into an `Ok` with a truncated result.
1262        let err = paginate_tags(EcosystemId::Swift, "owner/repo", |page| async move {
1263            match page {
1264                1..=3 => Ok(tags_page_json(100)),
1265                4 => Err(DepsError::CacheError("boom from page 4".to_string())),
1266                5..=6 => {
1267                    tokio::time::sleep(Duration::from_millis(20)).await;
1268                    Ok(tags_page_json(100))
1269                }
1270                _ => panic!("page {page} must not be fetched"),
1271            }
1272        })
1273        .await
1274        .unwrap_err();
1275
1276        assert!(
1277            err.to_string().contains("boom from page 4"),
1278            "must propagate page 4's own error, got: {err}"
1279        );
1280    }
1281
1282    #[cfg(feature = "test-util")]
1283    #[tokio::test]
1284    async fn test_paginate_tags_warns_when_cap_reached_with_full_last_page() {
1285        use std::sync::atomic::{AtomicU32, Ordering};
1286
1287        let calls = AtomicU32::new(0);
1288        let output = capture_tracing_output_async(async {
1289            let result = paginate_tags(EcosystemId::Swift, "owner/repo", |_page| {
1290                calls.fetch_add(1, Ordering::SeqCst);
1291                async move { Ok(tags_page_json(100)) }
1292            })
1293            .await
1294            .unwrap();
1295            assert_eq!(result.items.len(), 100 * MAX_TAG_PAGES as usize);
1296            assert_eq!(result.coverage, crate::pagination::ListCoverage::Truncated);
1297        })
1298        .await;
1299
1300        assert_eq!(calls.load(Ordering::SeqCst), MAX_TAG_PAGES);
1301        assert!(output.contains("owner/repo"), "output was: {output}");
1302        assert!(output.contains("cap"), "output was: {output}");
1303    }
1304
1305    // --- parse_tags_page ---
1306
1307    #[test]
1308    fn test_parse_tags_page_returns_raw_tags() {
1309        let json = r#"[{"name": "v1.0.0"}, {"name": "not-semver"}]"#;
1310        let tags = parse_tags_page(json.as_bytes()).unwrap();
1311        assert_eq!(tags.len(), 2);
1312        assert_eq!(tags[0].name, "v1.0.0");
1313    }
1314
1315    #[test]
1316    fn test_parse_tags_page_missing_commit_defaults() {
1317        // The GitHub Actions caller needs `commit.sha`; the Swift caller ignores it
1318        // entirely. Both must deserialize a page whose entries omit `commit` outright.
1319        let json = r#"[{"name": "1.0.0"}]"#;
1320        let tags = parse_tags_page(json.as_bytes()).unwrap();
1321        assert_eq!(tags.len(), 1);
1322        assert_eq!(tags[0].commit.sha, "");
1323    }
1324
1325    #[test]
1326    fn test_parse_tags_page_invalid_json_returns_empty() {
1327        let result = parse_tags_page(b"not json").unwrap();
1328        assert!(result.is_empty());
1329    }
1330
1331    #[test]
1332    fn test_parse_tags_page_github_rate_limit_returns_error() {
1333        let json = r#"{"message":"API rate limit exceeded for 1.2.3.4."}"#;
1334        let result = parse_tags_page(json.as_bytes());
1335        assert!(result.is_err());
1336        assert!(result.unwrap_err().to_string().contains("rate limit"));
1337    }
1338
1339    // --- classify_tags_fetch_error (#1295) ---
1340
1341    /// Critic S1/S2: an already-verified `RateLimited` from `crate::cache`'s registry-neutral
1342    /// check gets the GitHub-specific remedy swapped in, keeping
1343    /// `verified: RateLimitEvidence::Confirmed` — and the resulting message is distinct from
1344    /// the unverified one (S2).
1345    #[test]
1346    fn test_classify_tags_fetch_error_enriches_verified_rate_limit_with_github_hint() {
1347        let generic = DepsError::RateLimited {
1348            message: "registry rate limit exceeded (confirmed by the response)".into(),
1349            verified: RateLimitEvidence::Confirmed,
1350            source_status: Some(403),
1351        };
1352        let classified = classify_tags_fetch_error(generic, "owner/repo", "GitHub", false);
1353        match classified {
1354            DepsError::RateLimited {
1355                message, verified, ..
1356            } => {
1357                assert_eq!(verified, RateLimitEvidence::Confirmed);
1358                assert!(message.contains("GITHUB_TOKEN"));
1359                assert_ne!(message, GITHUB_RATE_LIMIT_MESSAGE_UNVERIFIED);
1360            }
1361            other => panic!("expected RateLimited, got {other:?}"),
1362        }
1363    }
1364
1365    #[test]
1366    fn test_classify_tags_fetch_error_unverified_403_no_token() {
1367        let e = DepsError::HttpStatus {
1368            url: "https://api.github.com/repos/owner/repo/tags".into(),
1369            status: 403,
1370        };
1371        let classified = classify_tags_fetch_error(e, "owner/repo", "GitHub", false);
1372        assert!(matches!(
1373            classified,
1374            DepsError::RateLimited {
1375                verified: RateLimitEvidence::Inferred,
1376                ..
1377            }
1378        ));
1379    }
1380
1381    #[test]
1382    fn test_classify_tags_fetch_error_tokened_403_passes_through_unchanged() {
1383        let e = DepsError::HttpStatus {
1384            url: "https://api.github.com/repos/owner/repo/tags".into(),
1385            status: 403,
1386        };
1387        let classified = classify_tags_fetch_error(e, "owner/repo", "GitHub", true);
1388        assert!(matches!(
1389            classified,
1390            DepsError::HttpStatus { status: 403, .. }
1391        ));
1392    }
1393
1394    // --- GithubTagsClient ---
1395
1396    #[test]
1397    fn test_github_tags_client_for_test_sets_token_header() {
1398        let client = GithubTagsClient::for_test(Arc::new(HttpCache::new()), "http://example", true);
1399        assert!(client.has_token());
1400        assert_eq!(client.api_base(), "http://example");
1401        assert_eq!(client.headers().len(), 1);
1402    }
1403
1404    #[test]
1405    fn test_github_tags_client_for_test_no_token_has_no_headers() {
1406        let client =
1407            GithubTagsClient::for_test(Arc::new(HttpCache::new()), "http://example", false);
1408        assert!(!client.has_token());
1409        assert!(client.headers().is_empty());
1410    }
1411
1412    // --- ApiToken redaction (bare-type coverage lives in `crate::secret`'s own tests) ---
1413
1414    #[test]
1415    fn test_api_token_debug_redacts_when_embedded_in_header_vec() {
1416        // Guards against a future `#[derive(Debug)]` on `GithubTagsClient` (or a struct
1417        // embedding it) accidentally printing a raw `GITHUB_TOKEN` value: exercises the
1418        // exact shape `GithubTagsClient::auth_headers` stores, `Vec<(HeaderName,
1419        // ApiToken)>`, not just a bare `ApiToken`.
1420        let token = crate::secret::ApiToken::new("Bearer super-secret-value".to_string());
1421        let headers = vec![(AUTHORIZATION, token)];
1422        let debug_output = format!("{headers:?}");
1423        assert!(
1424            !debug_output.contains("super-secret-value"),
1425            "{debug_output}"
1426        );
1427        assert!(debug_output.contains("ApiToken(***)"), "{debug_output}");
1428    }
1429
1430    // --- fetch_authenticated: wire-level behavior ---
1431
1432    #[tokio::test]
1433    async fn test_fetch_authenticated_sends_token_on_the_wire() {
1434        let mut server = mockito::Server::new_async().await;
1435        let mock = server
1436            .mock("GET", "/repos/owner/repo/tags?per_page=100&page=1")
1437            .match_header("authorization", "Bearer test-token")
1438            .with_status(200)
1439            .with_body("[]")
1440            .create_async()
1441            .await;
1442
1443        let client = GithubTagsClient::for_test(Arc::new(HttpCache::new()), server.url(), true);
1444        client.fetch_tags_page("owner/repo", 1).await.unwrap();
1445
1446        mock.assert_async().await;
1447    }
1448
1449    #[tokio::test]
1450    async fn test_fetch_authenticated_blocks_cross_origin_redirect() {
1451        // Two separate `mockito::Server` instances bind to distinct ports, so a 302 from
1452        // one to the other is a genuine cross-origin redirect the trusted-origin pin must
1453        // stop (mirrors `cache::tests::test_get_cached_trusted_origin_stops_cross_origin_redirect`).
1454        let mut trusted_server = mockito::Server::new_async().await;
1455        let mut other_server = mockito::Server::new_async().await;
1456
1457        let escape_target = format!("{}/stolen", other_server.url());
1458        let _redirect = trusted_server
1459            .mock("GET", "/repos/owner/repo/tags?per_page=100&page=1")
1460            .with_status(302)
1461            .with_header("location", &escape_target)
1462            .create_async()
1463            .await;
1464        let escape = other_server
1465            .mock("GET", "/stolen")
1466            .with_status(200)
1467            .with_body("must not be returned")
1468            .expect(0)
1469            .create_async()
1470            .await;
1471
1472        let client =
1473            GithubTagsClient::for_test(Arc::new(HttpCache::new()), trusted_server.url(), true);
1474        let result = client.fetch_tags_page("owner/repo", 1).await;
1475
1476        // Extract only the status code rather than formatting `result` itself: the error
1477        // carries no sensitive data, but CodeQL's cleartext-logging check flags any format
1478        // of a value derived from a call chain that touched the client's auth headers.
1479        let status = match result {
1480            Err(DepsError::HttpStatus { status, .. }) => Some(status),
1481            _ => None,
1482        };
1483        assert_eq!(
1484            status,
1485            Some(302),
1486            "expected the cross-origin redirect to be stopped"
1487        );
1488        escape.assert_async().await;
1489    }
1490
1491    // --- normalize_tag ---
1492
1493    #[test]
1494    fn test_normalize_tag_strips_lowercase_v() {
1495        assert_eq!(normalize_tag("v1.2.3"), "1.2.3");
1496    }
1497
1498    #[test]
1499    fn test_normalize_tag_strips_uppercase_v() {
1500        assert_eq!(normalize_tag("V1.2.3"), "1.2.3");
1501    }
1502
1503    #[test]
1504    fn test_normalize_tag_no_prefix_unchanged() {
1505        assert_eq!(normalize_tag("1.2.3"), "1.2.3");
1506    }
1507
1508    // --- parse_releases_page ---
1509
1510    #[test]
1511    fn test_parse_releases_page_happy_path() {
1512        let json = br#"[
1513            {"tag_name": "2.0.0", "published_at": "2026-01-02T08:56:05Z", "draft": false},
1514            {"tag_name": "1.0.0", "published_at": "2025-06-01T00:00:00Z", "draft": false}
1515        ]"#;
1516        let dates = parse_releases_page(json).unwrap();
1517        assert_eq!(dates.len(), 2);
1518        assert!(dates.contains_key("2.0.0"));
1519        assert!(dates.contains_key("1.0.0"));
1520    }
1521
1522    #[test]
1523    fn test_parse_releases_page_skips_draft() {
1524        let json = br#"[
1525            {"tag_name": "2.0.0", "published_at": "2026-01-02T08:56:05Z", "draft": true},
1526            {"tag_name": "1.0.0", "published_at": "2025-06-01T00:00:00Z", "draft": false}
1527        ]"#;
1528        let dates = parse_releases_page(json).unwrap();
1529        assert_eq!(dates.len(), 1);
1530        assert!(!dates.contains_key("2.0.0"));
1531        assert!(dates.contains_key("1.0.0"));
1532    }
1533
1534    #[test]
1535    fn test_parse_releases_page_skips_null_published_at() {
1536        let json = br#"[
1537            {"tag_name": "2.0.0", "published_at": null, "draft": false}
1538        ]"#;
1539        let dates = parse_releases_page(json).unwrap();
1540        assert!(dates.is_empty());
1541    }
1542
1543    #[test]
1544    fn test_parse_releases_page_v_prefix_joins_tag() {
1545        let json =
1546            br#"[{"tag_name": "V1.2.3", "published_at": "2026-01-02T08:56:05Z", "draft": false}]"#;
1547        let dates = parse_releases_page(json).unwrap();
1548        assert!(dates.contains_key("1.2.3"));
1549        assert!(!dates.contains_key("V1.2.3"));
1550    }
1551
1552    #[test]
1553    fn test_parse_releases_page_empty_array_is_a_successful_zero_release_page() {
1554        // A repo with genuinely no releases must parse as `Some(empty)`, not `None` — the
1555        // caller relies on this to pick the positive TTL, not the error TTL (#223 S3).
1556        let dates = parse_releases_page(b"[]");
1557        assert_eq!(dates, Some(HashMap::new()));
1558    }
1559
1560    #[test]
1561    fn test_parse_releases_page_malformed_json_returns_none() {
1562        assert_eq!(parse_releases_page(b"not json"), None);
1563    }
1564
1565    #[test]
1566    fn test_parse_releases_page_github_error_object_returns_none() {
1567        let json = br#"{"message":"API rate limit exceeded for 1.2.3.4."}"#;
1568        assert_eq!(parse_releases_page(json), None);
1569    }
1570
1571    #[test]
1572    fn test_parse_releases_page_duplicate_normalized_keys_first_wins() {
1573        // GitHub returns releases in `created_at` desc order, so the first entry in the
1574        // array is the newest; a second release pointing at the same normalized tag must
1575        // not overwrite it (#223 M2).
1576        let json = br#"[
1577            {"tag_name": "v1.0.0", "published_at": "2026-06-01T00:00:00Z", "draft": false},
1578            {"tag_name": "1.0.0", "published_at": "2025-01-01T00:00:00Z", "draft": false}
1579        ]"#;
1580        let dates = parse_releases_page(json).unwrap();
1581        assert_eq!(dates.len(), 1);
1582        assert_eq!(
1583            dates.get("1.0.0").copied(),
1584            PublishTime::parse_rfc3339("2026-06-01T00:00:00Z")
1585        );
1586    }
1587
1588    // --- classify_release_fetch (the memo's write-side TTL decision, #223 S3) ---
1589
1590    #[test]
1591    fn test_classify_release_fetch_success_gets_positive_ttl() {
1592        let json =
1593            br#"[{"tag_name": "1.0.0", "published_at": "2026-01-02T08:56:05Z", "draft": false}]"#;
1594        let (dates, ttl) = classify_release_fetch(Some(Ok(Bytes::from_static(json))));
1595        assert_eq!(dates.len(), 1);
1596        assert_eq!(ttl, RELEASE_DATES_TTL);
1597    }
1598
1599    #[test]
1600    fn test_classify_release_fetch_empty_but_valid_page_gets_positive_ttl() {
1601        let (dates, ttl) = classify_release_fetch(Some(Ok(Bytes::from_static(b"[]"))));
1602        assert!(dates.is_empty());
1603        assert_eq!(ttl, RELEASE_DATES_TTL);
1604    }
1605
1606    #[test]
1607    fn test_classify_release_fetch_unparseable_body_gets_error_ttl() {
1608        let (dates, ttl) = classify_release_fetch(Some(Ok(Bytes::from_static(b"not json"))));
1609        assert!(dates.is_empty());
1610        assert_eq!(ttl, RELEASE_DATES_ERROR_TTL);
1611    }
1612
1613    #[test]
1614    fn test_classify_release_fetch_http_error_gets_error_ttl() {
1615        let (dates, ttl) =
1616            classify_release_fetch(Some(Err(DepsError::CacheError("boom".to_string()))));
1617        assert!(dates.is_empty());
1618        assert_eq!(ttl, RELEASE_DATES_ERROR_TTL);
1619    }
1620
1621    #[test]
1622    fn test_classify_release_fetch_timeout_gets_error_ttl() {
1623        let (dates, ttl) = classify_release_fetch(None);
1624        assert!(dates.is_empty());
1625        assert_eq!(ttl, RELEASE_DATES_ERROR_TTL);
1626    }
1627
1628    // --- evict_release_dates_if_full ---
1629
1630    fn entry_at(secs_ago: u64, ttl: Duration) -> ReleaseDatesEntry {
1631        ReleaseDatesEntry {
1632            fetched_at: Instant::now()
1633                .checked_sub(Duration::from_secs(secs_ago))
1634                .unwrap(),
1635            dates: Arc::new(HashMap::new()),
1636            ttl,
1637        }
1638    }
1639
1640    #[test]
1641    fn test_evict_release_dates_if_full_noop_under_cap() {
1642        let map = DashMap::new();
1643        map.insert("a/a".to_string(), entry_at(0, RELEASE_DATES_TTL));
1644        evict_release_dates_if_full(&map);
1645        assert_eq!(map.len(), 1);
1646    }
1647
1648    #[test]
1649    fn test_evict_release_dates_if_full_drops_expired_entries_first() {
1650        let map = DashMap::new();
1651        for i in 0..MAX_RELEASE_DATES_MEMO_ENTRIES {
1652            // Every entry expired against its own (error) TTL.
1653            map.insert(
1654                format!("owner/repo{i}"),
1655                entry_at(1000, RELEASE_DATES_ERROR_TTL),
1656            );
1657        }
1658        assert_eq!(map.len(), MAX_RELEASE_DATES_MEMO_ENTRIES);
1659        evict_release_dates_if_full(&map);
1660        assert!(
1661            map.is_empty(),
1662            "all entries were expired and must be dropped"
1663        );
1664    }
1665
1666    #[test]
1667    fn test_evict_release_dates_if_full_drops_oldest_when_none_expired() {
1668        let map = DashMap::new();
1669        for i in 0..MAX_RELEASE_DATES_MEMO_ENTRIES {
1670            // All alive (well within TTL), but with distinct ages so one is oldest.
1671            map.insert(
1672                format!("owner/repo{i}"),
1673                entry_at(i as u64, RELEASE_DATES_TTL),
1674            );
1675        }
1676        assert_eq!(map.len(), MAX_RELEASE_DATES_MEMO_ENTRIES);
1677        evict_release_dates_if_full(&map);
1678        assert_eq!(
1679            map.len(),
1680            MAX_RELEASE_DATES_MEMO_ENTRIES - 1,
1681            "exactly one entry (the oldest) must be evicted"
1682        );
1683        // The oldest entry (largest secs_ago == MAX_RELEASE_DATES_MEMO_ENTRIES - 1) must be
1684        // gone.
1685        assert!(!map.contains_key(&format!("owner/repo{}", MAX_RELEASE_DATES_MEMO_ENTRIES - 1)));
1686        // The newest entry must survive.
1687        assert!(map.contains_key("owner/repo0"));
1688    }
1689
1690    // --- ReleaseDatesCache::fetch: memo behavior ---
1691
1692    /// Builds a [`GithubTagsClient`] with `has_token` pinned to `false`, independent of the
1693    /// ambient `GITHUB_TOKEN` environment variable (CI runners, e.g. GitHub Actions, often
1694    /// inject one automatically), so these tests stay deterministic and never attempt a real
1695    /// network request.
1696    fn untokened_client() -> GithubTagsClient {
1697        GithubTagsClient::for_test(Arc::new(HttpCache::new()), GITHUB_API, false)
1698    }
1699
1700    #[tokio::test]
1701    async fn test_fetch_validate_owner_repo_rejection_issues_zero_requests() {
1702        let cache = ReleaseDatesCache::new();
1703        let github = untokened_client();
1704        let dates = cache
1705            .fetch(&github, "../../etc/passwd", EcosystemId::Swift)
1706            .await;
1707        assert!(dates.is_empty());
1708        // Nothing stored: a validation failure is cheaper to re-check than to memoize
1709        // (#223 M6), and this also proves no fetch-and-store path ran.
1710        assert!(cache.dates.is_empty());
1711    }
1712
1713    #[cfg(feature = "test-util")]
1714    #[tokio::test]
1715    async fn test_fetch_positive_ttl_hit_returns_memoized_value_without_refetch() {
1716        let cache = ReleaseDatesCache::new();
1717        let github = untokened_client();
1718        // has_token is false, so any code path that falls through to a live fetch would
1719        // both return an *empty* map and log the skip message — this fixture (a non-empty,
1720        // synthetic dataset a real GitHub call could never produce) is only observable if
1721        // the positive-TTL memo-hit branch returned early.
1722        let published = PublishTime::parse_rfc3339("2026-01-02T08:56:05Z").unwrap();
1723        cache.dates.insert(
1724            format!("{GITHUB_API}\0owner/repo"),
1725            ReleaseDatesEntry {
1726                fetched_at: Instant::now().checked_sub(Duration::from_secs(60)).unwrap(),
1727                dates: Arc::new(HashMap::from([("9.9.9".to_string(), published)])),
1728                ttl: RELEASE_DATES_TTL,
1729            },
1730        );
1731
1732        let output = capture_tracing_output_async(async {
1733            let dates = cache.fetch(&github, "owner/repo", EcosystemId::Swift).await;
1734            assert_eq!(dates.get("9.9.9").copied(), Some(published));
1735        })
1736        .await;
1737        assert!(
1738            output.is_empty(),
1739            "memo hit must not log the token-gate skip: {output}"
1740        );
1741    }
1742
1743    #[cfg(feature = "test-util")]
1744    #[tokio::test]
1745    async fn test_fetch_empty_but_successful_fetch_retained_under_positive_ttl() {
1746        let cache = ReleaseDatesCache::new();
1747        let github = untokened_client();
1748        // An empty dates map stored under the *positive* TTL (simulating a repo with
1749        // genuinely no releases) must be trusted as-is, not treated as if it had failed and
1750        // needed a retry within the (much shorter) error TTL.
1751        cache.dates.insert(
1752            format!("{GITHUB_API}\0owner/repo"),
1753            ReleaseDatesEntry {
1754                fetched_at: Instant::now().checked_sub(Duration::from_secs(60)).unwrap(),
1755                dates: Arc::new(HashMap::new()),
1756                ttl: RELEASE_DATES_TTL,
1757            },
1758        );
1759
1760        let output = capture_tracing_output_async(async {
1761            let dates = cache.fetch(&github, "owner/repo", EcosystemId::Swift).await;
1762            assert!(dates.is_empty());
1763        })
1764        .await;
1765        assert!(
1766            output.is_empty(),
1767            "an empty-but-successful entry within its positive TTL must not refetch: {output}"
1768        );
1769    }
1770
1771    #[cfg(feature = "test-util")]
1772    #[tokio::test]
1773    async fn test_fetch_unexpired_error_ttl_entry_is_memo_hit() {
1774        let cache = ReleaseDatesCache::new();
1775        let github = untokened_client();
1776        // A failure recorded under the (short) error TTL, 60s ago, is still within that 90s
1777        // window: it must be honored as a memo hit, not treated as expired.
1778        cache.dates.insert(
1779            format!("{GITHUB_API}\0owner/repo"),
1780            ReleaseDatesEntry {
1781                fetched_at: Instant::now().checked_sub(Duration::from_secs(60)).unwrap(),
1782                dates: Arc::new(HashMap::new()),
1783                ttl: RELEASE_DATES_ERROR_TTL,
1784            },
1785        );
1786
1787        let output = capture_tracing_output_async(async {
1788            let dates = cache.fetch(&github, "owner/repo", EcosystemId::Swift).await;
1789            assert!(dates.is_empty());
1790        })
1791        .await;
1792        assert!(
1793            output.is_empty(),
1794            "an unexpired error-TTL entry must not refetch: {output}"
1795        );
1796    }
1797
1798    #[cfg(feature = "test-util")]
1799    #[tokio::test]
1800    async fn test_fetch_expired_error_ttl_entry_falls_through_to_token_gate() {
1801        let cache = ReleaseDatesCache::new();
1802        let github = untokened_client();
1803        // 100s ago exceeds RELEASE_DATES_ERROR_TTL (90s): the entry must be treated as
1804        // expired and a refetch attempted. `has_token` is false, so the refetch resolves
1805        // locally (no network) via the token-gate skip, which logs.
1806        cache.dates.insert(
1807            format!("{GITHUB_API}\0owner/repo"),
1808            ReleaseDatesEntry {
1809                fetched_at: Instant::now()
1810                    .checked_sub(Duration::from_secs(100))
1811                    .unwrap(),
1812                dates: Arc::new(HashMap::new()),
1813                ttl: RELEASE_DATES_ERROR_TTL,
1814            },
1815        );
1816
1817        let output = capture_tracing_output_async(async {
1818            let dates = cache.fetch(&github, "owner/repo", EcosystemId::Swift).await;
1819            assert!(dates.is_empty());
1820        })
1821        .await;
1822        assert!(
1823            output.contains("GITHUB_TOKEN not set"),
1824            "expiry must trigger a refetch attempt: {output}"
1825        );
1826    }
1827
1828    #[cfg(feature = "test-util")]
1829    #[tokio::test]
1830    async fn test_fetch_token_gate_skip_logs_once_per_cache_and_names_ecosystem() {
1831        let cache = ReleaseDatesCache::new();
1832        let github = untokened_client();
1833
1834        let output = capture_tracing_output_async(async {
1835            let _ = cache
1836                .fetch(&github, "owner/repo-a", EcosystemId::Swift)
1837                .await;
1838            let _ = cache
1839                .fetch(&github, "owner/repo-b", EcosystemId::Swift)
1840                .await;
1841        })
1842        .await;
1843
1844        assert_eq!(
1845            output.matches("GITHUB_TOKEN not set").count(),
1846            1,
1847            "the skip message must fire at most once per cache instance: {output}"
1848        );
1849        assert!(
1850            output.contains("swift release dates are unavailable"),
1851            "{output}"
1852        );
1853    }
1854
1855    #[test]
1856    fn test_error_ttl_is_shorter_than_positive_ttl() {
1857        assert!(RELEASE_DATES_ERROR_TTL < RELEASE_DATES_TTL);
1858    }
1859
1860    #[cfg(feature = "test-util")]
1861    #[tokio::test]
1862    async fn test_fetch_does_not_serve_a_hit_seeded_under_a_different_api_base() {
1863        // #486 critic M1: the cache is keyed on `(api_base, name)`, not `name` alone, so two
1864        // `GithubTagsClient`s pointed at different origins sharing one cache (a mock server
1865        // beside the real API, or a future GitHub Enterprise base) cannot cross-serve a hit.
1866        let cache = ReleaseDatesCache::new();
1867        let published = PublishTime::parse_rfc3339("2026-01-02T08:56:05Z").unwrap();
1868        cache.dates.insert(
1869            format!("{GITHUB_API}\0owner/repo"),
1870            ReleaseDatesEntry {
1871                fetched_at: Instant::now().checked_sub(Duration::from_secs(60)).unwrap(),
1872                dates: Arc::new(HashMap::from([("9.9.9".to_string(), published)])),
1873                ttl: RELEASE_DATES_TTL,
1874            },
1875        );
1876
1877        // A client pointed at a different origin, but the same `owner/repo`, must not see
1878        // the entry seeded under `GITHUB_API` above — it falls through to the (untokened)
1879        // fetch path instead, observable via the token-gate skip log.
1880        let other_origin_client =
1881            GithubTagsClient::for_test(Arc::new(HttpCache::new()), "http://127.0.0.1:1", false);
1882        let output = capture_tracing_output_async(async {
1883            let dates = cache
1884                .fetch(&other_origin_client, "owner/repo", EcosystemId::Swift)
1885                .await;
1886            assert!(dates.is_empty());
1887        })
1888        .await;
1889        assert!(
1890            output.contains("GITHUB_TOKEN not set"),
1891            "a different api_base must miss the memo and fall through: {output}"
1892        );
1893    }
1894
1895    #[test]
1896    fn canonical_repo_name_from_commit_url_extracts_owner_repo_casing() {
1897        let name = CanonicalRepoName::from_commit_url(
1898            "https://api.github.com/repos/Azure/setup-kubectl/commits/0123abcd",
1899        )
1900        .expect("well-formed commit url");
1901        assert_eq!(name.as_str(), "Azure/setup-kubectl");
1902    }
1903
1904    #[test]
1905    fn canonical_repo_name_ignores_trailing_segments_after_commits() {
1906        let name = CanonicalRepoName::from_commit_url(
1907            "https://api.github.com/repos/Azure/setup-kubectl/commits/abc/extra",
1908        )
1909        .expect("owner/repo/commits prefix is what identifies the repository");
1910        assert_eq!(name.as_str(), "Azure/setup-kubectl");
1911        let bare = CanonicalRepoName::from_commit_url("https://api.github.com/repos/o/r/commits")
1912            .expect("the sha is not needed to identify the repository");
1913        assert_eq!(bare.as_str(), "o/r");
1914    }
1915
1916    #[test]
1917    fn canonical_repo_name_rejects_malformed_commit_urls() {
1918        for url in [
1919            "",
1920            "https://api.github.com/repos/Azure",
1921            "https://api.github.com/repos/Azure/setup-kubectl",
1922            "https://api.github.com/repos/Azure/setup-kubectl/tags/abc",
1923            "https://api.github.com/repos/../setup-kubectl/commits/abc",
1924            "https://api.github.com/repos/Az ure/setup-kubectl/commits/abc",
1925            "https://api.github.com/repos//r/commits/x",
1926            "https://api.github.com/repos/o//commits/x",
1927        ] {
1928            assert!(CanonicalRepoName::from_commit_url(url).is_none(), "{url}");
1929        }
1930    }
1931}