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