Skip to main content

osdk_core/http/
mod.rs

1//! Shared HTTP client factory and helpers for downloads and JSON index fetches.
2
3use std::time::Duration;
4
5use reqwest::header::{HeaderMap, HeaderName, HeaderValue, LOCATION};
6
7use crate::backend::Ctx;
8use crate::error::{Error, GithubRateLimitInfo, Result};
9use crate::source::Source;
10
11/// Build the shared reqwest client (rustls, gzip, redirects, sane timeouts).
12pub fn client() -> Result<reqwest::Client> {
13    client_builder()
14        .redirect(reqwest::redirect::Policy::limited(10))
15        .build()
16        .map_err(Error::from)
17}
18
19fn client_builder() -> reqwest::ClientBuilder {
20    reqwest::Client::builder()
21        .user_agent(concat!("osdk/", env!("CARGO_PKG_VERSION")))
22        .connect_timeout(Duration::from_secs(15))
23        .pool_idle_timeout(Duration::from_secs(30))
24}
25
26/// Fetch a URL and deserialize the JSON body.
27pub async fn get_json<T: serde::de::DeserializeOwned>(
28    client: &reqwest::Client,
29    url: &str,
30) -> Result<T> {
31    let resp = client.get(url).send().await?.error_for_status()?;
32    let bytes = resp.bytes().await?;
33    Ok(serde_json::from_slice(&bytes)?)
34}
35
36/// Fetch a URL and return the body as text.
37pub async fn get_text(client: &reqwest::Client, url: &str) -> Result<String> {
38    let resp = client.get(url).send().await?.error_for_status()?;
39    Ok(resp.text().await?)
40}
41
42/// Fetch JSON with a persistent URL-keyed cache. Online requests refresh the
43/// cache; failures fall back to stale data. Offline mode never makes a request.
44pub async fn get_cached_json<T: serde::de::DeserializeOwned>(ctx: &Ctx, url: &str) -> Result<T> {
45    get_cached_json_inner(ctx, url, false, None).await
46}
47
48/// Fetch cached JSON with explicitly configured source headers.
49///
50/// These headers are independent of `Source::forward_credentials`: that flag
51/// controls ambient/provider credentials, while `Source::headers` is explicit
52/// user configuration. Explicit headers are sent only when the initial request
53/// has the exact origin of the source's configured index or download URL. They
54/// survive same-origin redirects and are permanently removed after the first
55/// cross-origin redirect. Header values are represented in the cache key only
56/// by a hash and are never written to the metadata cache.
57pub async fn get_cached_source_json<T: serde::de::DeserializeOwned>(
58    ctx: &Ctx,
59    source: &Source,
60    url: &str,
61) -> Result<T> {
62    get_cached_json_inner(ctx, url, false, Some(source)).await
63}
64
65/// Source-aware metadata cache path. Explicit header values are represented by
66/// hashes so callers can persist bounded responses without leaking secrets or
67/// aliasing differently authenticated metadata.
68pub(crate) fn source_metadata_cache_path(
69    ctx: &Ctx,
70    source: &Source,
71    url: &str,
72) -> Result<std::path::PathBuf> {
73    Ok(metadata_cache_path(
74        ctx,
75        &source_metadata_cache_identity(url, source)?,
76    ))
77}
78
79/// Fetch text with the same stale-cache behavior as [`get_cached_json`].
80pub async fn get_cached_text(ctx: &Ctx, url: &str) -> Result<String> {
81    let cache_file = metadata_cache_path(ctx, url);
82    let (bytes, fresh) = get_cached_bytes(ctx, url, false, None).await?;
83    match String::from_utf8(bytes) {
84        Ok(text) => {
85            if fresh {
86                write_metadata_cache(&cache_file, text.as_bytes());
87            }
88            Ok(text)
89        }
90        Err(error) if fresh => {
91            let stale = std::fs::read(&cache_file)
92                .map_err(|_| Error::other(format!("invalid UTF-8 from {url}: {error}")))?;
93            String::from_utf8(stale).map_err(|stale_error| {
94                Error::other(format!("invalid cached UTF-8 for {url}: {stale_error}"))
95            })
96        }
97        Err(error) => Err(Error::other(format!("invalid UTF-8 from {url}: {error}"))),
98    }
99}
100
101/// Fetch JSON from the GitHub API with the recommended headers, honoring a
102/// `GITHUB_TOKEN`/`GH_TOKEN` env var to raise the rate limit when present.
103/// GitHub returns 403 for API requests missing an `Accept`/`X-GitHub-Api-Version`
104/// header under load, so we always send them.
105pub async fn get_github_json<T: serde::de::DeserializeOwned>(
106    client: &reqwest::Client,
107    url: &str,
108) -> Result<T> {
109    get_github_json_from_urls(client, &[url.to_string()]).await
110}
111
112/// Try multiple transports for one GitHub JSON resource. Authorization is sent
113/// only to the official GitHub API host, never to a third-party proxy.
114pub async fn get_github_json_from_urls<T: serde::de::DeserializeOwned>(
115    client: &reqwest::Client,
116    urls: &[String],
117) -> Result<T> {
118    let mut last_error = None;
119    let mut rate_limit_error = None;
120    let token_configured =
121        github_token().is_some() && urls.iter().any(|url| should_send_github_token(url));
122    for url in urls {
123        match fetch_github_bytes(client, url).await {
124            Ok(bytes) => match serde_json::from_slice(&bytes) {
125                Ok(value) => return Ok(value),
126                Err(error) => last_error = Some(Error::Json(error)),
127            },
128            Err(error) if matches!(error, Error::GithubRateLimited { .. }) => {
129                remember_rate_limit(&mut rate_limit_error, error, token_configured);
130            }
131            Err(error) => last_error = Some(error),
132        }
133    }
134    Err(select_github_error(rate_limit_error, last_error))
135}
136
137/// GitHub API variant of [`get_cached_json`], preserving GitHub headers and
138/// token handling while adding stale/offline cache behavior.
139pub async fn get_cached_github_json<T: serde::de::DeserializeOwned>(
140    ctx: &Ctx,
141    url: &str,
142) -> Result<T> {
143    get_cached_github_json_from_urls(ctx, url, &[url.to_string()]).await
144}
145
146/// Cached GitHub JSON with transport failover. `cache_identity` is the
147/// canonical upstream URL, so direct and proxied transports share one cache.
148pub async fn get_cached_github_json_from_urls<T: serde::de::DeserializeOwned>(
149    ctx: &Ctx,
150    cache_identity: &str,
151    urls: &[String],
152) -> Result<T> {
153    let cache_file = metadata_cache_path(ctx, cache_identity);
154    if ctx.config.settings.offline {
155        let bytes = std::fs::read(&cache_file).map_err(|_| {
156            Error::other(format!(
157                "offline metadata cache miss for {cache_identity} (run once without --offline)"
158            ))
159        })?;
160        return Ok(serde_json::from_slice(&bytes)?);
161    }
162
163    let mut last_error = None;
164    let mut rate_limit_error = None;
165    let token_configured =
166        github_token().is_some() && urls.iter().any(|url| should_send_github_token(url));
167    for url in urls {
168        match fetch_github_bytes(&ctx.client, url).await {
169            Ok(bytes) => match serde_json::from_slice(&bytes) {
170                Ok(value) => {
171                    write_metadata_cache(&cache_file, &bytes);
172                    return Ok(value);
173                }
174                Err(error) => last_error = Some(Error::Json(error)),
175            },
176            Err(error) if matches!(error, Error::GithubRateLimited { .. }) => {
177                remember_rate_limit(&mut rate_limit_error, error, token_configured);
178            }
179            Err(error) => last_error = Some(error),
180        }
181    }
182
183    match std::fs::read(&cache_file) {
184        Ok(bytes) => {
185            tracing::warn!(
186                path = %cache_file.display(),
187                "using stale cached metadata after all GitHub transports failed"
188            );
189            Ok(serde_json::from_slice(&bytes)?)
190        }
191        Err(_) => Err(select_github_error(rate_limit_error, last_error)),
192    }
193}
194
195fn remember_rate_limit(slot: &mut Option<Error>, error: Error, token_configured: bool) {
196    let authenticated = matches!(
197        error,
198        Error::GithubRateLimited {
199            authenticated: true,
200            ..
201        }
202    );
203    if (authenticated || !token_configured) && (authenticated || slot.is_none()) {
204        *slot = Some(error);
205    }
206}
207
208fn select_github_error(rate_limit_error: Option<Error>, last_error: Option<Error>) -> Error {
209    match (rate_limit_error, last_error) {
210        (Some(rate_limit), Some(last))
211            if matches!(
212                rate_limit,
213                Error::GithubRateLimited {
214                    authenticated: false,
215                    ..
216                }
217            ) && last.status() == Some(403) =>
218        {
219            last
220        }
221        (Some(rate_limit), _) => rate_limit,
222        (None, Some(last)) => last,
223        (None, None) => Error::other("no GitHub API URL candidates"),
224    }
225}
226
227/// Fetch public GitHub Web metadata through ordered transports, sharing one
228/// canonical cache entry and retaining stale/offline behavior. Unlike API
229/// requests this deliberately sends neither API headers nor authorization.
230pub async fn get_cached_text_from_urls(
231    ctx: &Ctx,
232    cache_identity: &str,
233    urls: &[String],
234    validator: impl Fn(&str) -> bool,
235) -> Result<String> {
236    let cache_file = metadata_cache_path(ctx, cache_identity);
237    if ctx.config.settings.offline {
238        let bytes = std::fs::read(&cache_file).map_err(|_| {
239            Error::other(format!(
240                "offline metadata cache miss for {cache_identity} (run once without --offline)"
241            ))
242        })?;
243        let text = String::from_utf8(bytes).map_err(|error| {
244            Error::other(format!(
245                "invalid cached UTF-8 for {cache_identity}: {error}"
246            ))
247        })?;
248        return validator(&text)
249            .then_some(text)
250            .ok_or_else(|| invalid_metadata(cache_identity));
251    }
252
253    let mut last_error = None;
254    for url in urls {
255        match fetch_public_bytes(&ctx.client, url).await {
256            Ok(bytes) => match String::from_utf8(bytes) {
257                Ok(text) if validator(&text) => {
258                    write_metadata_cache(&cache_file, text.as_bytes());
259                    return Ok(text);
260                }
261                Ok(_) => last_error = Some(invalid_metadata(url)),
262                Err(error) => {
263                    last_error = Some(Error::other(format!("invalid UTF-8 from {url}: {error}")))
264                }
265            },
266            Err(error) => last_error = Some(error),
267        }
268    }
269
270    match std::fs::read(&cache_file) {
271        Ok(bytes) => {
272            tracing::warn!(
273                path = %cache_file.display(),
274                "using stale cached metadata after all public GitHub transports failed"
275            );
276            let text = String::from_utf8(bytes).map_err(|error| {
277                Error::other(format!(
278                    "invalid cached UTF-8 for {cache_identity}: {error}"
279                ))
280            })?;
281            validator(&text)
282                .then_some(text)
283                .ok_or_else(|| last_error.unwrap_or_else(|| invalid_metadata(cache_identity)))
284        }
285        Err(_) => {
286            Err(last_error.unwrap_or_else(|| Error::other("no public GitHub URL candidates")))
287        }
288    }
289}
290
291fn invalid_metadata(url: &str) -> Error {
292    Error::Network {
293        kind: crate::error::NetworkErrorKind::InvalidMetadata,
294        url: url.into(),
295        status: None,
296    }
297}
298
299async fn get_cached_json_inner<T: serde::de::DeserializeOwned>(
300    ctx: &Ctx,
301    url: &str,
302    github: bool,
303    source: Option<&Source>,
304) -> Result<T> {
305    let cache_identity = match source {
306        Some(source) => source_metadata_cache_identity(url, source)?,
307        None => url.to_string(),
308    };
309    let cache_file = metadata_cache_path(ctx, &cache_identity);
310    let (bytes, fresh) = get_cached_bytes(ctx, url, github, source).await?;
311    match serde_json::from_slice(&bytes) {
312        Ok(value) => {
313            if fresh {
314                write_metadata_cache(&cache_file, &bytes);
315            }
316            Ok(value)
317        }
318        Err(_) if fresh => {
319            let stale = std::fs::read(&cache_file).map_err(|_| Error::Network {
320                kind: crate::error::NetworkErrorKind::InvalidMetadata,
321                url: url.into(),
322                status: None,
323            })?;
324            Ok(serde_json::from_slice(&stale)?)
325        }
326        Err(_) => Err(Error::Network {
327            kind: crate::error::NetworkErrorKind::InvalidMetadata,
328            url: url.into(),
329            status: None,
330        }),
331    }
332}
333
334async fn get_cached_bytes(
335    ctx: &Ctx,
336    url: &str,
337    github: bool,
338    source: Option<&Source>,
339) -> Result<(Vec<u8>, bool)> {
340    let cache_identity = match source {
341        Some(source) => source_metadata_cache_identity(url, source)?,
342        None => url.to_string(),
343    };
344    let cache_file = metadata_cache_path(ctx, &cache_identity);
345    if ctx.config.settings.offline {
346        return std::fs::read(&cache_file)
347            .map(|bytes| (bytes, false))
348            .map_err(|_| {
349                Error::other(format!(
350                    "offline metadata cache miss for {url} (run once without --offline)"
351                ))
352            });
353    }
354
355    let result: Result<reqwest::Response> = if let Some(source) = source {
356        send_source_get(&ctx.client, source, url).await
357    } else if github {
358        github_request(&ctx.client, url)
359            .send()
360            .await
361            .map_err(|error| Error::network(url, error))
362    } else {
363        ctx.client
364            .get(url)
365            .send()
366            .await
367            .map_err(|error| Error::network(url, error))
368    };
369
370    match result {
371        Ok(response) => match response.error_for_status() {
372            Ok(response) => match response.bytes().await {
373                Ok(bytes) => Ok((bytes.to_vec(), true)),
374                Err(error) => read_stale_or_error(&cache_file, Error::network(url, error)),
375            },
376            Err(error) => read_stale_or_error(&cache_file, Error::network(url, error)),
377        },
378        Err(error @ Error::Config(_)) => Err(error),
379        Err(error) => read_stale_or_error(&cache_file, error),
380    }
381}
382
383/// Send a GET with source headers constrained to configured source origins.
384///
385/// A no-redirect client is used so arbitrary header names can be removed at
386/// the origin boundary. Reqwest's default redirect handling strips standard
387/// credentials such as `Authorization`, but deliberately does not know that a
388/// user-defined header such as `X-Api-Key` may also contain a secret.
389async fn send_source_get(
390    base_client: &reqwest::Client,
391    source: &Source,
392    url: &str,
393) -> Result<reqwest::Response> {
394    let Some(source_headers) = source_headers_for_url(source, url)? else {
395        return base_client
396            .get(url)
397            .send()
398            .await
399            .map_err(|error| Error::network(url, error));
400    };
401    send_get_with_redirect_headers(source_client()?, url, source_headers, true).await
402}
403
404fn source_client() -> Result<&'static reqwest::Client> {
405    static CLIENT: once_cell::sync::OnceCell<reqwest::Client> = once_cell::sync::OnceCell::new();
406    CLIENT.get_or_try_init(|| {
407        client_builder()
408            .redirect(reqwest::redirect::Policy::none())
409            .build()
410            .map_err(Error::from)
411    })
412}
413
414fn source_headers_for_url(source: &Source, url: &str) -> Result<Option<HeaderMap>> {
415    let headers = source_header_map(source)?;
416    if headers.is_empty() {
417        return Ok(None);
418    }
419    let url = reqwest::Url::parse(url)
420        .map_err(|error| Error::config(format!("invalid source URL `{url}`: {error}")))?;
421    let origin = url_origin(&url);
422    Ok(origin
423        .as_ref()
424        .is_some_and(|origin| {
425            source_origins(source)
426                .iter()
427                .any(|allowed| allowed == origin)
428        })
429        .then_some(headers))
430}
431
432/// Fetch a source URL with the same origin-bound header policy used for
433/// metadata. This is exposed for source probes, which need to stream the body.
434pub(crate) async fn get_source_response(
435    client: &reqwest::Client,
436    source: &Source,
437    url: &str,
438) -> Result<reqwest::Response> {
439    send_source_get(client, source, url).await
440}
441
442async fn send_get_with_redirect_headers(
443    client: &reqwest::Client,
444    url: &str,
445    headers: HeaderMap,
446    mut attach_headers: bool,
447) -> Result<reqwest::Response> {
448    let mut current = reqwest::Url::parse(url)
449        .map_err(|error| Error::config(format!("invalid URL `{url}`: {error}")))?;
450    for redirect_count in 0..=10 {
451        let mut request = client.get(current.clone());
452        if attach_headers {
453            request = request.headers(headers.clone());
454        }
455        let response = request
456            .send()
457            .await
458            .map_err(|error| Error::network(current.as_str(), error))?;
459        if !matches!(
460            response.status(),
461            reqwest::StatusCode::MOVED_PERMANENTLY
462                | reqwest::StatusCode::FOUND
463                | reqwest::StatusCode::SEE_OTHER
464                | reqwest::StatusCode::TEMPORARY_REDIRECT
465                | reqwest::StatusCode::PERMANENT_REDIRECT
466        ) {
467            return Ok(response);
468        }
469        let Some(location) = response.headers().get(LOCATION) else {
470            return Ok(response);
471        };
472        let Ok(location) = location.to_str() else {
473            return Ok(response);
474        };
475        let Ok(next) = current.join(location) else {
476            return Ok(response);
477        };
478        if !matches!(next.scheme(), "http" | "https") {
479            return Ok(response);
480        }
481        if redirect_count == 10 {
482            return Err(Error::other(format!(
483                "too many redirects while requesting {url}"
484            )));
485        }
486        attach_headers = attach_headers && url_origin(&current) == url_origin(&next);
487        current = next;
488    }
489    unreachable!("bounded redirect loop always returns")
490}
491
492/// Parse configured headers into a request map. Callers must still enforce the
493/// source-origin boundary; successful parsing never grants a destination.
494fn source_header_map(source: &Source) -> Result<HeaderMap> {
495    let mut headers = HeaderMap::new();
496    for (name, value) in &source.headers {
497        let name = HeaderName::from_bytes(name.as_bytes())
498            .map_err(|error| Error::config(format!("invalid HTTP header `{name}`: {error}")))?;
499        let mut value = HeaderValue::from_str(value)
500            .map_err(|error| Error::config(format!("invalid HTTP header value: {error}")))?;
501        value.set_sensitive(true);
502        headers.insert(name, value);
503    }
504    Ok(headers)
505}
506
507fn source_origins(source: &Source) -> Vec<(String, String, u16)> {
508    [
509        source.index_url.as_deref(),
510        Some(source.download_url.as_str()),
511    ]
512    .into_iter()
513    .flatten()
514    .filter_map(|url| reqwest::Url::parse(url).ok())
515    .filter_map(|url| url_origin(&url))
516    .fold(Vec::new(), |mut origins, origin| {
517        if !origins.contains(&origin) {
518            origins.push(origin);
519        }
520        origins
521    })
522}
523
524fn url_origin(url: &reqwest::Url) -> Option<(String, String, u16)> {
525    Some((
526        url.scheme().to_ascii_lowercase(),
527        url.host_str()?.to_ascii_lowercase(),
528        url.port_or_known_default()?,
529    ))
530}
531
532fn source_metadata_cache_identity(url: &str, source: &Source) -> Result<String> {
533    if source_headers_for_url(source, url)?.is_none() {
534        return Ok(url.to_string());
535    }
536    let mut headers = source
537        .headers
538        .iter()
539        .map(|(name, value)| {
540            (
541                name.to_ascii_lowercase(),
542                blake3::hash(value.as_bytes()).to_hex().to_string(),
543            )
544        })
545        .collect::<Vec<_>>();
546    headers.sort();
547    let encoded = serde_json::to_vec(&(url, headers)).unwrap_or_default();
548    Ok(format!("source:{}", blake3::hash(&encoded).to_hex()))
549}
550
551async fn fetch_github_bytes(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
552    let response = github_request(client, url)
553        .send()
554        .await
555        .map_err(|error| Error::network(url, error))?;
556    if !response.status().is_success() {
557        return Err(github_response_error(
558            url,
559            should_send_github_token(url) && github_token().is_some(),
560            response,
561        )
562        .await);
563    }
564    Ok(response
565        .bytes()
566        .await
567        .map_err(|error| Error::network(url, error))?
568        .to_vec())
569}
570
571async fn fetch_public_bytes(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
572    let response = client
573        .get(url)
574        .send()
575        .await
576        .map_err(|error| Error::network(url, error))?
577        .error_for_status()
578        .map_err(|error| Error::network(url, error))?;
579    Ok(response
580        .bytes()
581        .await
582        .map_err(|error| Error::network(url, error))?
583        .to_vec())
584}
585
586async fn github_response_error(
587    url: &str,
588    authenticated: bool,
589    mut response: reqwest::Response,
590) -> Error {
591    const MAX_ERROR_BODY: usize = 64 * 1024;
592
593    let status = response.status();
594    let headers = response.headers().clone();
595    let mut body = Vec::new();
596    while body.len() < MAX_ERROR_BODY {
597        match response.chunk().await {
598            Ok(Some(chunk)) => {
599                let remaining = MAX_ERROR_BODY - body.len();
600                body.extend_from_slice(&chunk[..chunk.len().min(remaining)]);
601            }
602            Ok(None) | Err(_) => break,
603        }
604    }
605    let body_text = String::from_utf8_lossy(&body);
606    let body_json = serde_json::from_slice::<serde_json::Value>(&body).ok();
607    let message = body_json
608        .as_ref()
609        .and_then(|value| value.get("message"))
610        .and_then(serde_json::Value::as_str)
611        .map(str::to_string);
612    let documentation = body_json
613        .as_ref()
614        .and_then(|value| value.get("documentation_url"))
615        .and_then(serde_json::Value::as_str)
616        .unwrap_or_default();
617    let lower_body = body_text.to_ascii_lowercase();
618    let retry_after = header_text(&headers, reqwest::header::RETRY_AFTER);
619    let remaining_is_zero =
620        header_text(&headers, "x-ratelimit-remaining").is_some_and(|value| value.trim() == "0");
621    let github_api_response = should_send_github_token(url)
622        || headers.contains_key("x-github-request-id")
623        || headers.contains_key("x-ratelimit-resource")
624        || (headers.contains_key("x-ratelimit-limit")
625            && headers.contains_key("x-ratelimit-remaining")
626            && headers.contains_key("x-ratelimit-reset"));
627    let rate_limited = github_api_response
628        && (status == reqwest::StatusCode::TOO_MANY_REQUESTS
629            || (status == reqwest::StatusCode::FORBIDDEN
630                && (retry_after.is_some()
631                    || remaining_is_zero
632                    || lower_body.contains("api rate limit exceeded")
633                    || lower_body.contains("secondary rate limit")
634                    || lower_body.contains("abuse detection mechanism")
635                    || documentation.to_ascii_lowercase().contains("rate-limit")
636                    || documentation.to_ascii_lowercase().contains("rate_limits"))));
637
638    if rate_limited {
639        return Error::GithubRateLimited {
640            url: url.into(),
641            status: status.as_u16(),
642            authenticated,
643            info: GithubRateLimitInfo {
644                message,
645                reset: header_text(&headers, "x-ratelimit-reset"),
646                retry_after,
647            },
648        };
649    }
650
651    response
652        .error_for_status()
653        .map(|_| unreachable!("non-success GitHub response became successful"))
654        .unwrap_or_else(|error| Error::network(url, error))
655}
656
657fn header_text(
658    headers: &reqwest::header::HeaderMap,
659    name: impl reqwest::header::AsHeaderName,
660) -> Option<String> {
661    headers
662        .get(name)
663        .and_then(|value| value.to_str().ok())
664        .map(str::trim)
665        .filter(|value| !value.is_empty())
666        .map(str::to_string)
667}
668
669pub(crate) fn github_request(client: &reqwest::Client, url: &str) -> reqwest::RequestBuilder {
670    let mut request = client
671        .get(url)
672        .header(reqwest::header::ACCEPT, "application/vnd.github+json")
673        .header("X-GitHub-Api-Version", "2022-11-28");
674    if should_send_github_token(url) {
675        if let Some(token) = github_token() {
676            request = request.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"));
677        }
678    }
679    request
680}
681
682fn should_send_github_token(url: &str) -> bool {
683    reqwest::Url::parse(url)
684        .ok()
685        .and_then(|url| url.host_str().map(str::to_owned))
686        .is_some_and(|host| host.eq_ignore_ascii_case("api.github.com"))
687}
688
689/// Rewrite an official GitHub API, raw-content, or release URL through one
690/// configured source. Sources whose bases embed the canonical GitHub URL (such
691/// as `https://gh-proxy.com/https://github.com/`) also proxy raw content.
692pub fn github_url_for_source(source: &Source, original: &str) -> String {
693    const API_BASE: &str = "https://api.github.com/";
694    const DOWNLOAD_BASE: &str = "https://github.com/";
695    const RAW_BASE: &str = "https://raw.githubusercontent.com/";
696    const GIST_BASE: &str = "https://gist.githubusercontent.com/";
697
698    if let Some(path) = original.strip_prefix(API_BASE) {
699        return source
700            .index_url
701            .as_deref()
702            .map(|base| join_url(base, path))
703            .unwrap_or_else(|| original.to_string());
704    }
705    if let Some(path) = original.strip_prefix(DOWNLOAD_BASE) {
706        return join_url(&source.download_url, path);
707    }
708    if original.starts_with(RAW_BASE) || original.starts_with(GIST_BASE) {
709        if let Some(prefix) = github_proxy_prefix(source) {
710            return format!("{prefix}{original}");
711        }
712    }
713    original.to_string()
714}
715
716/// Build unique candidate transports in source order for one GitHub resource.
717pub fn github_url_candidates(sources: &[Source], original: &str) -> Vec<String> {
718    let canonical = canonical_github_url(sources, original);
719    let mut urls = Vec::new();
720    for source in sources {
721        let url = github_url_for_source(source, &canonical);
722        if !urls.iter().any(|candidate| candidate == &url) {
723            urls.push(url);
724        }
725    }
726    if urls.is_empty() {
727        urls.push(canonical);
728    }
729    urls
730}
731
732fn canonical_github_url(sources: &[Source], url: &str) -> String {
733    for source in sources {
734        if let Some(prefix) = github_proxy_prefix(source) {
735            if let Some(original) = url.strip_prefix(prefix) {
736                if original.starts_with("https://") {
737                    return original.to_string();
738                }
739            }
740        }
741    }
742    url.to_string()
743}
744
745fn github_proxy_prefix(source: &Source) -> Option<&str> {
746    for value in [
747        Some(source.download_url.as_str()),
748        source.index_url.as_deref(),
749    ]
750    .into_iter()
751    .flatten()
752    {
753        for canonical in ["https://github.com/", "https://api.github.com/"] {
754            if let Some((prefix, _)) = value.split_once(canonical) {
755                if !prefix.is_empty() {
756                    return Some(prefix);
757                }
758            }
759        }
760    }
761    None
762}
763
764pub(crate) fn metadata_cache_path(ctx: &Ctx, url: &str) -> std::path::PathBuf {
765    let hash = blake3::hash(url.as_bytes()).to_hex().to_string();
766    ctx.dirs.remote_cache().join("http").join(hash)
767}
768
769fn write_metadata_cache(path: &std::path::Path, bytes: &[u8]) {
770    if let Some(parent) = path.parent() {
771        let _ = std::fs::create_dir_all(parent);
772    }
773    let temporary = path.with_extension(format!("tmp-{}", std::process::id()));
774    if std::fs::write(&temporary, bytes).is_ok() {
775        let _ = std::fs::rename(&temporary, path);
776    }
777}
778
779fn read_stale_or_error(path: &std::path::Path, error: Error) -> Result<(Vec<u8>, bool)> {
780    match std::fs::read(path) {
781        Ok(bytes) => {
782            tracing::warn!(path = %path.display(), "using stale cached metadata after request failure");
783            Ok((bytes, false))
784        }
785        Err(_) => Err(error),
786    }
787}
788
789/// Read a GitHub token from the usual env vars, if set and non-empty.
790pub fn github_token() -> Option<String> {
791    for key in ["OSDK_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"] {
792        if let Ok(v) = std::env::var(key) {
793            let v = v.trim().to_string();
794            if !v.is_empty() {
795                return Some(v);
796            }
797        }
798    }
799    None
800}
801
802/// Substitute `{version}`, `{os}`, `{arch}`, `{file}`, `{ext}` placeholders in a
803/// URL template. Joins base + tail if the template ends with `/`.
804pub fn render_template(template: &str, vars: &[(&str, &str)]) -> String {
805    let mut out = template.to_string();
806    for (k, v) in vars {
807        out = out.replace(&format!("{{{k}}}"), v);
808    }
809    out
810}
811
812/// Join a base URL (which may or may not end in `/`) with a path tail.
813pub fn join_url(base: &str, tail: &str) -> String {
814    let base = base.trim_end_matches('/');
815    let tail = tail.trim_start_matches('/');
816    format!("{base}/{tail}")
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822    use std::io::{Read, Write};
823    use std::net::TcpListener;
824
825    fn test_ctx(root: &std::path::Path, offline: bool) -> Ctx {
826        let dirs = crate::dirs::Dirs::resolve_from(|key| match key {
827            "OSDK_DATA_DIR" => Some(root.join("data").display().to_string()),
828            "OSDK_CACHE_DIR" => Some(root.join("cache").display().to_string()),
829            "OSDK_CONFIG_DIR" => Some(root.join("config").display().to_string()),
830            _ => None,
831        })
832        .unwrap();
833        dirs.ensure().unwrap();
834        let settings = crate::config::Settings {
835            offline,
836            ..Default::default()
837        };
838        Ctx {
839            dirs: dirs.clone(),
840            platform: crate::platform::Platform::current(),
841            config: crate::config::Config {
842                settings,
843                sources: Default::default(),
844                tools: Default::default(),
845                tool_configs: Default::default(),
846                global_tools: Default::default(),
847                global_tool_configs: Default::default(),
848                tool_origins: Default::default(),
849                aliases: Default::default(),
850                project_config_path: None,
851            },
852            client: reqwest::Client::new(),
853            cas: std::sync::Arc::new(crate::store::Cas::new(dirs.store.clone())),
854            show_progress: false,
855        }
856    }
857
858    #[test]
859    fn template_render() {
860        let t = "https://host/v{version}/node-v{version}-{os}-{arch}.{ext}";
861        let got = render_template(
862            t,
863            &[
864                ("version", "20.11.1"),
865                ("os", "linux"),
866                ("arch", "x64"),
867                ("ext", "tar.gz"),
868            ],
869        );
870        assert_eq!(got, "https://host/v20.11.1/node-v20.11.1-linux-x64.tar.gz");
871    }
872
873    #[test]
874    fn url_join() {
875        assert_eq!(
876            join_url("https://h/dist/", "/index.json"),
877            "https://h/dist/index.json"
878        );
879        assert_eq!(
880            join_url("https://h/dist", "index.json"),
881            "https://h/dist/index.json"
882        );
883    }
884
885    #[test]
886    fn source_cache_identity_hashes_headers_without_persisting_values() {
887        let mut source = Source::mirror("private", "https://registry.example.test/", 1);
888        source.headers = vec![("X-Api-Key".into(), "secret-one".into())];
889        let first =
890            source_metadata_cache_identity("https://registry.example.test/tool", &source).unwrap();
891        source.headers[0].1 = "secret-two".into();
892        let second =
893            source_metadata_cache_identity("https://registry.example.test/tool", &source).unwrap();
894
895        assert_ne!(first, second);
896        assert!(!first.contains("secret-one"));
897        assert!(!second.contains("secret-two"));
898        assert_eq!(
899            source_metadata_cache_identity("https://other.example.test/tool", &source).unwrap(),
900            "https://other.example.test/tool"
901        );
902    }
903
904    #[test]
905    fn source_headers_are_explicit_and_validate_independently_of_credentials() {
906        let mut source = Source::mirror("private", "https://registry.example.test/", 1);
907        source.forward_credentials = false;
908        source.headers = vec![("X-Api-Key".into(), "secret".into())];
909        assert!(
910            source_headers_for_url(&source, "https://registry.example.test/tool")
911                .unwrap()
912                .is_some()
913        );
914        assert!(
915            source_headers_for_url(&source, "https://other.example.test/tool")
916                .unwrap()
917                .is_none()
918        );
919
920        source.headers = vec![("invalid header".into(), "value".into())];
921        assert!(matches!(
922            source_headers_for_url(&source, "https://registry.example.test/tool"),
923            Err(Error::Config(_))
924        ));
925    }
926
927    #[tokio::test]
928    async fn source_headers_survive_same_origin_redirects() {
929        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
930        let address = listener.local_addr().unwrap();
931        let server = std::thread::spawn(move || {
932            for index in 0..2 {
933                let (mut stream, _) = listener.accept().unwrap();
934                let request = read_request(&mut stream);
935                assert!(request.to_ascii_lowercase().contains("x-api-key: secret"));
936                if index == 0 {
937                    stream
938                        .write_all(
939                            b"HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
940                        )
941                        .unwrap();
942                } else {
943                    stream
944                        .write_all(
945                            b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
946                        )
947                        .unwrap();
948                }
949            }
950        });
951        let url = format!("http://{address}/start");
952        let mut source = Source::mirror("private", &format!("http://{address}/"), 1);
953        source.headers = vec![("X-Api-Key".into(), "secret".into())];
954        let client = reqwest::Client::builder()
955            .redirect(reqwest::redirect::Policy::none())
956            .build()
957            .unwrap();
958
959        let response = send_source_get(&client, &source, &url).await.unwrap();
960        assert_eq!(response.status(), reqwest::StatusCode::OK);
961        server.join().unwrap();
962    }
963
964    #[tokio::test]
965    async fn source_headers_are_removed_after_cross_origin_redirect() {
966        let target = TcpListener::bind("127.0.0.1:0").unwrap();
967        let target_address = target.local_addr().unwrap();
968        let target_server = std::thread::spawn(move || {
969            let (mut stream, _) = target.accept().unwrap();
970            let request = read_request(&mut stream);
971            assert!(!request.to_ascii_lowercase().contains("x-api-key:"));
972            stream
973                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
974                .unwrap();
975        });
976        let origin = TcpListener::bind("127.0.0.1:0").unwrap();
977        let origin_address = origin.local_addr().unwrap();
978        let origin_server = std::thread::spawn(move || {
979            let (mut stream, _) = origin.accept().unwrap();
980            let request = read_request(&mut stream);
981            assert!(request.to_ascii_lowercase().contains("x-api-key: secret"));
982            write!(
983                stream,
984                "HTTP/1.1 302 Found\r\nLocation: http://{target_address}/final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
985            )
986            .unwrap();
987        });
988        let url = format!("http://{origin_address}/start");
989        let mut source = Source::mirror("private", &format!("http://{origin_address}/"), 1);
990        source.headers = vec![("X-Api-Key".into(), "secret".into())];
991        let client = reqwest::Client::builder()
992            .redirect(reqwest::redirect::Policy::none())
993            .build()
994            .unwrap();
995
996        let response = send_source_get(&client, &source, &url).await.unwrap();
997        assert_eq!(response.status(), reqwest::StatusCode::OK);
998        origin_server.join().unwrap();
999        target_server.join().unwrap();
1000    }
1001
1002    fn read_request(stream: &mut std::net::TcpStream) -> String {
1003        let mut request = Vec::new();
1004        let mut buffer = [0u8; 1024];
1005        while !request.ends_with(b"\r\n\r\n") {
1006            let read = stream.read(&mut buffer).unwrap();
1007            if read == 0 {
1008                break;
1009            }
1010            request.extend_from_slice(&buffer[..read]);
1011        }
1012        String::from_utf8(request).unwrap()
1013    }
1014
1015    #[tokio::test]
1016    async fn network_failure_matrix_has_stable_error_kinds_and_stale_fallback() {
1017        use crate::error::NetworkErrorKind;
1018
1019        for (status, expected) in [
1020            ("403 Forbidden", NetworkErrorKind::Forbidden),
1021            ("429 Too Many Requests", NetworkErrorKind::RateLimited),
1022            ("503 Service Unavailable", NetworkErrorKind::Server),
1023        ] {
1024            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1025            let address = listener.local_addr().unwrap();
1026            let server = std::thread::spawn(move || {
1027                let (mut stream, _) = listener.accept().unwrap();
1028                let mut buffer = [0u8; 1024];
1029                let _ = stream.read(&mut buffer);
1030                write!(
1031                    stream,
1032                    "HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1033                )
1034                .unwrap();
1035            });
1036            let temp = tempfile::tempdir().unwrap();
1037            let ctx = test_ctx(temp.path(), false);
1038            let url = format!("http://{address}/metadata");
1039            let error = get_cached_json::<serde_json::Value>(&ctx, &url)
1040                .await
1041                .unwrap_err();
1042            assert!(matches!(
1043                error,
1044                Error::Network { kind, .. } if kind == expected
1045            ));
1046            server.join().unwrap();
1047        }
1048
1049        let temp = tempfile::tempdir().unwrap();
1050        let ctx = test_ctx(temp.path(), false);
1051        let url = "http://127.0.0.1:9/unreachable";
1052        let error = get_cached_json::<serde_json::Value>(&ctx, url)
1053            .await
1054            .unwrap_err();
1055        assert!(matches!(
1056            error,
1057            Error::Network {
1058                kind: NetworkErrorKind::Connect,
1059                ..
1060            }
1061        ));
1062
1063        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1064        let address = listener.local_addr().unwrap();
1065        let server = std::thread::spawn(move || {
1066            let (mut stream, _) = listener.accept().unwrap();
1067            let mut buffer = [0u8; 1024];
1068            let _ = stream.read(&mut buffer);
1069            let body = "not-json";
1070            write!(
1071                stream,
1072                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1073                body.len(),
1074                body
1075            )
1076            .unwrap();
1077        });
1078        let malformed_url = format!("http://{address}/metadata");
1079        let malformed = get_cached_json::<serde_json::Value>(&ctx, &malformed_url)
1080            .await
1081            .unwrap_err();
1082        assert!(matches!(
1083            malformed,
1084            Error::Network {
1085                kind: NetworkErrorKind::InvalidMetadata,
1086                ..
1087            }
1088        ));
1089        server.join().unwrap();
1090
1091        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1092        let address = listener.local_addr().unwrap();
1093        let server = std::thread::spawn(move || {
1094            let (mut stream, _) = listener.accept().unwrap();
1095            let mut buffer = [0u8; 1024];
1096            let _ = stream.read(&mut buffer);
1097            stream
1098                .write_all(
1099                    b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1100                )
1101                .unwrap();
1102        });
1103        let stale_url = format!("http://{address}/stale");
1104        let stale_path = metadata_cache_path(&ctx, &stale_url);
1105        write_metadata_cache(&stale_path, br#"{"cached":true}"#);
1106        let stale: serde_json::Value = get_cached_json(&ctx, &stale_url).await.unwrap();
1107        assert_eq!(stale["cached"], true);
1108        server.join().unwrap();
1109    }
1110
1111    #[tokio::test]
1112    async fn request_timeout_is_classified() {
1113        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1114        let address = listener.local_addr().unwrap();
1115        let server = std::thread::spawn(move || {
1116            let (_stream, _) = listener.accept().unwrap();
1117            std::thread::sleep(std::time::Duration::from_millis(250));
1118        });
1119        let temp = tempfile::tempdir().unwrap();
1120        let mut ctx = test_ctx(temp.path(), false);
1121        ctx.client = reqwest::Client::builder()
1122            .timeout(std::time::Duration::from_millis(30))
1123            .build()
1124            .unwrap();
1125        let url = format!("http://{address}/slow");
1126        let error = get_cached_json::<serde_json::Value>(&ctx, &url)
1127            .await
1128            .unwrap_err();
1129        assert!(matches!(
1130            error,
1131            Error::Network {
1132                kind: crate::error::NetworkErrorKind::Timeout,
1133                ..
1134            }
1135        ));
1136        server.join().unwrap();
1137    }
1138
1139    #[test]
1140    fn github_source_rewrites_api_raw_and_release_urls() {
1141        let direct =
1142            Source::official("github", "https://github.com/").with_index("https://api.github.com/");
1143        let proxy = Source::mirror("ghproxy", "https://gh-proxy.com/https://github.com/", 10)
1144            .with_index("https://gh-proxy.com/https://api.github.com/");
1145
1146        assert_eq!(
1147            github_url_for_source(
1148                &direct,
1149                "https://api.github.com/repos/cli/cli/releases?per_page=30"
1150            ),
1151            "https://api.github.com/repos/cli/cli/releases?per_page=30"
1152        );
1153        assert_eq!(
1154            github_url_for_source(
1155                &proxy,
1156                "https://api.github.com/repos/cli/cli/releases?per_page=30"
1157            ),
1158            "https://gh-proxy.com/https://api.github.com/repos/cli/cli/releases?per_page=30"
1159        );
1160        assert_eq!(
1161            github_url_for_source(
1162                &proxy,
1163                "https://github.com/cli/cli/releases/download/v1.0.0/gh.tar.gz"
1164            ),
1165            "https://gh-proxy.com/https://github.com/cli/cli/releases/download/v1.0.0/gh.tar.gz"
1166        );
1167        assert_eq!(
1168            github_url_for_source(
1169                &proxy,
1170                "https://raw.githubusercontent.com/cli/cli/main/README.md"
1171            ),
1172            "https://gh-proxy.com/https://raw.githubusercontent.com/cli/cli/main/README.md"
1173        );
1174        assert_eq!(
1175            github_url_for_source(
1176                &proxy,
1177                "https://gist.githubusercontent.com/user/id/raw/file"
1178            ),
1179            "https://gh-proxy.com/https://gist.githubusercontent.com/user/id/raw/file"
1180        );
1181    }
1182
1183    #[test]
1184    fn github_candidates_follow_source_order_without_duplicates() {
1185        let proxy = Source::mirror("ghproxy", "https://gh-proxy.com/https://github.com/", 10)
1186            .with_index("https://gh-proxy.com/https://api.github.com/");
1187        let direct =
1188            Source::official("github", "https://github.com/").with_index("https://api.github.com/");
1189        let duplicate = Source::mirror("duplicate", "https://github.com/", 20)
1190            .with_index("https://api.github.com/");
1191
1192        assert_eq!(
1193            github_url_candidates(
1194                &[proxy, direct, duplicate],
1195                "https://api.github.com/repos/cli/cli/releases"
1196            ),
1197            vec![
1198                "https://gh-proxy.com/https://api.github.com/repos/cli/cli/releases",
1199                "https://api.github.com/repos/cli/cli/releases",
1200            ]
1201        );
1202    }
1203
1204    #[test]
1205    fn github_candidates_canonicalize_a_locked_proxy_url() {
1206        let proxy = Source::mirror("ghproxy", "https://gh-proxy.com/https://github.com/", 10)
1207            .with_index("https://gh-proxy.com/https://api.github.com/");
1208        let direct =
1209            Source::official("github", "https://github.com/").with_index("https://api.github.com/");
1210
1211        assert_eq!(
1212            github_url_candidates(
1213                &[direct, proxy],
1214                "https://gh-proxy.com/https://github.com/cli/cli/releases/download/v1/gh.tar.gz"
1215            ),
1216            vec![
1217                "https://github.com/cli/cli/releases/download/v1/gh.tar.gz",
1218                "https://gh-proxy.com/https://github.com/cli/cli/releases/download/v1/gh.tar.gz",
1219            ]
1220        );
1221    }
1222
1223    #[test]
1224    fn github_token_is_limited_to_official_api_host() {
1225        assert!(should_send_github_token(
1226            "https://api.github.com/repos/cli/cli/releases"
1227        ));
1228        assert!(!should_send_github_token(
1229            "https://gh-proxy.com/https://api.github.com/repos/cli/cli/releases"
1230        ));
1231        assert!(!should_send_github_token(
1232            "https://raw.githubusercontent.com/cli/cli/main/README.md"
1233        ));
1234    }
1235
1236    #[tokio::test]
1237    async fn cached_json_is_available_offline_without_a_request() {
1238        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1239        let address = listener.local_addr().unwrap();
1240        let server = std::thread::spawn(move || {
1241            let (mut stream, _) = listener.accept().unwrap();
1242            let mut request = Vec::new();
1243            let mut buffer = [0u8; 1024];
1244            while !request.ends_with(b"\r\n\r\n") {
1245                let read = stream.read(&mut buffer).unwrap();
1246                if read == 0 {
1247                    break;
1248                }
1249                request.extend_from_slice(&buffer[..read]);
1250            }
1251            let body = r#"{"versions":["1.2.3"]}"#;
1252            write!(
1253                stream,
1254                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1255                body.len(),
1256                body
1257            )
1258            .unwrap();
1259        });
1260
1261        let temp = tempfile::tempdir().unwrap();
1262        let url = format!("http://{address}/metadata.json");
1263        let online = test_ctx(temp.path(), false);
1264        let value: serde_json::Value = get_cached_json(&online, &url).await.unwrap();
1265        assert_eq!(value["versions"][0], "1.2.3");
1266        server.join().unwrap();
1267
1268        let offline = test_ctx(temp.path(), true);
1269        let value: serde_json::Value = get_cached_json(&offline, &url).await.unwrap();
1270        assert_eq!(value["versions"][0], "1.2.3");
1271    }
1272
1273    #[tokio::test]
1274    async fn cached_github_json_fails_over_transports_and_reuses_one_cache() {
1275        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1276        let address = listener.local_addr().unwrap();
1277        let server = std::thread::spawn(move || {
1278            for status in ["503 Service Unavailable", "200 OK"] {
1279                let (mut stream, _) = listener.accept().unwrap();
1280                let mut request = Vec::new();
1281                let mut buffer = [0u8; 1024];
1282                while !request.ends_with(b"\r\n\r\n") {
1283                    let read = stream.read(&mut buffer).unwrap();
1284                    if read == 0 {
1285                        break;
1286                    }
1287                    request.extend_from_slice(&buffer[..read]);
1288                }
1289                let body = if status == "200 OK" {
1290                    r#"{"versions":["2.0.0"]}"#
1291                } else {
1292                    r#"{"message":"retry elsewhere"}"#
1293                };
1294                write!(
1295                    stream,
1296                    "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1297                    body.len(),
1298                    body
1299                )
1300                .unwrap();
1301            }
1302        });
1303
1304        let temp = tempfile::tempdir().unwrap();
1305        let identity = "https://api.github.com/repos/example/tool/releases";
1306        let urls = vec![
1307            format!("http://{address}/direct"),
1308            format!("http://{address}/proxy"),
1309        ];
1310        let online = test_ctx(temp.path(), false);
1311        let value: serde_json::Value = get_cached_github_json_from_urls(&online, identity, &urls)
1312            .await
1313            .unwrap();
1314        assert_eq!(value["versions"][0], "2.0.0");
1315        server.join().unwrap();
1316
1317        let offline = test_ctx(temp.path(), true);
1318        let value: serde_json::Value = get_cached_github_json_from_urls(&offline, identity, &urls)
1319            .await
1320            .unwrap();
1321        assert_eq!(value["versions"][0], "2.0.0");
1322    }
1323
1324    #[tokio::test]
1325    async fn github_403_distinguishes_rate_limit_from_forbidden() {
1326        for (headers, body, rate_limited) in [
1327            (
1328                "X-RateLimit-Limit: 60\r\nX-RateLimit-Remaining: 0\r\nX-RateLimit-Reset: 1787446800\r\nRetry-After: 60\r\n",
1329                r#"{"message":"API rate limit exceeded for 203.0.113.10."}"#,
1330                true,
1331            ),
1332            (
1333                "X-RateLimit-Remaining: 4998\r\n",
1334                r#"{"message":"Resource not accessible by integration","documentation_url":"https://docs.github.com/rest/releases/releases"}"#,
1335                false,
1336            ),
1337        ] {
1338            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1339            let address = listener.local_addr().unwrap();
1340            let server = std::thread::spawn(move || {
1341                let (mut stream, _) = listener.accept().unwrap();
1342                let mut request = [0u8; 2048];
1343                let _ = stream.read(&mut request);
1344                write!(
1345                    stream,
1346                    "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
1347                    body.len(),
1348                )
1349                .unwrap();
1350            });
1351            let url = format!("http://{address}/repos/example/tool/releases");
1352            let error =
1353                get_github_json_from_urls::<serde_json::Value>(&reqwest::Client::new(), &[url])
1354                    .await
1355                    .unwrap_err();
1356            if rate_limited {
1357                match error {
1358                    Error::GithubRateLimited {
1359                        authenticated,
1360                        info,
1361                        ..
1362                    } => {
1363                        assert!(!authenticated);
1364                        assert_eq!(info.reset.as_deref(), Some("1787446800"));
1365                        assert_eq!(info.retry_after.as_deref(), Some("60"));
1366                        assert!(info.message.unwrap().contains("rate limit exceeded"));
1367                    }
1368                    other => panic!("expected rate-limit error, got {other}"),
1369                }
1370            } else {
1371                assert!(matches!(
1372                    error,
1373                    Error::Network {
1374                        kind: crate::error::NetworkErrorKind::Forbidden,
1375                        status: Some(403),
1376                        ..
1377                    }
1378                ));
1379            }
1380            server.join().unwrap();
1381        }
1382    }
1383
1384    #[tokio::test]
1385    async fn third_party_429_is_not_treated_as_anonymous_github_quota() {
1386        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1387        let address = listener.local_addr().unwrap();
1388        let server = std::thread::spawn(move || {
1389            let (mut stream, _) = listener.accept().unwrap();
1390            let mut request = [0u8; 2048];
1391            let _ = stream.read(&mut request);
1392            let body = r#"{"message":"proxy quota exhausted"}"#;
1393            write!(
1394                stream,
1395                "HTTP/1.1 429 Too Many Requests\r\nContent-Type: application/json\r\nRetry-After: 60\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1396                body.len(),
1397            )
1398            .unwrap();
1399        });
1400        let url = format!("http://{address}/proxy");
1401        let error = get_github_json_from_urls::<serde_json::Value>(&reqwest::Client::new(), &[url])
1402            .await
1403            .unwrap_err();
1404        assert!(matches!(
1405            error,
1406            Error::Network {
1407                kind: crate::error::NetworkErrorKind::RateLimited,
1408                ..
1409            }
1410        ));
1411        server.join().unwrap();
1412    }
1413
1414    #[tokio::test]
1415    async fn github_rate_limit_survives_a_later_proxy_failure() {
1416        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1417        let address = listener.local_addr().unwrap();
1418        let server = std::thread::spawn(move || {
1419            for (status, headers, body) in [
1420                (
1421                    "403 Forbidden",
1422                    "X-RateLimit-Limit: 60\r\nX-RateLimit-Remaining: 0\r\nX-RateLimit-Reset: 1787446800\r\n",
1423                    r#"{"message":"API rate limit exceeded"}"#,
1424                ),
1425                (
1426                    "503 Service Unavailable",
1427                    "",
1428                    r#"{"message":"proxy unavailable"}"#,
1429                ),
1430            ] {
1431                let (mut stream, _) = listener.accept().unwrap();
1432                let mut request = [0u8; 2048];
1433                let _ = stream.read(&mut request);
1434                write!(
1435                    stream,
1436                    "HTTP/1.1 {status}\r\nContent-Type: application/json\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
1437                    body.len(),
1438                )
1439                .unwrap();
1440            }
1441        });
1442        let urls = vec![
1443            format!("http://{address}/official"),
1444            format!("http://{address}/proxy"),
1445        ];
1446        let error = get_github_json_from_urls::<serde_json::Value>(&reqwest::Client::new(), &urls)
1447            .await
1448            .unwrap_err();
1449        assert!(matches!(error, Error::GithubRateLimited { .. }));
1450        assert!(error.to_string().contains("1787446800"));
1451        assert!(error.to_string().contains("OSDK_GITHUB_TOKEN"));
1452        server.join().unwrap();
1453    }
1454
1455    #[tokio::test]
1456    async fn public_metadata_skips_invalid_success_and_caches_only_valid_text() {
1457        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1458        let address = listener.local_addr().unwrap();
1459        let server = std::thread::spawn(move || {
1460            for body in ["<html>proxy error</html>", "<feed><entry/></feed>"] {
1461                let (mut stream, _) = listener.accept().unwrap();
1462                let mut request = [0u8; 2048];
1463                let _ = stream.read(&mut request);
1464                write!(
1465                    stream,
1466                    "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1467                    body.len(),
1468                )
1469                .unwrap();
1470            }
1471        });
1472        let temp = tempfile::tempdir().unwrap();
1473        let identity = "https://github.com/example/tool/releases.atom";
1474        let urls = vec![
1475            format!("http://{address}/bad-proxy"),
1476            format!("http://{address}/valid-direct"),
1477        ];
1478        let online = test_ctx(temp.path(), false);
1479        let text =
1480            get_cached_text_from_urls(&online, identity, &urls, |text| text.contains("<feed>"))
1481                .await
1482                .unwrap();
1483        assert_eq!(text, "<feed><entry/></feed>");
1484        server.join().unwrap();
1485
1486        let offline = test_ctx(temp.path(), true);
1487        let text =
1488            get_cached_text_from_urls(&offline, identity, &urls, |text| text.contains("<feed>"))
1489                .await
1490                .unwrap();
1491        assert_eq!(text, "<feed><entry/></feed>");
1492    }
1493
1494    #[tokio::test]
1495    async fn offline_cache_miss_is_explicit() {
1496        let temp = tempfile::tempdir().unwrap();
1497        let offline = test_ctx(temp.path(), true);
1498        let error = get_cached_text(&offline, "http://127.0.0.1:9/missing")
1499            .await
1500            .unwrap_err();
1501        assert!(error.to_string().contains("offline metadata cache miss"));
1502    }
1503}