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