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 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}