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}