Skip to main content

deps_core/
github.rs

1//! Shared GitHub tags-API client, used by every ecosystem that resolves package versions
2//! from GitHub repository tags rather than a dedicated package registry (`deps-swift`,
3//! `deps-github-actions`).
4//!
5//! Each ecosystem crate layers its own extras on top of [`GithubTagsClient`] —
6//! `deps-swift` adds GitHub Releases publish-date enrichment, `deps-github-actions` adds a
7//! rate-limit cooldown gate, in-flight request coalescing, and a tag<->SHA cross-reference.
8//! Only the pieces that were byte-for-byte identical between the two — constants,
9//! owner/repo validation, auth-header setup, the tags-pagination loop, and page parsing —
10//! live here, so the two crates cannot silently diverge on this shared behavior (#472).
11
12use crate::error::{DepsError, 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}