1use 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
11pub 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
26pub 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
36pub 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
42pub 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
48pub 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
65pub(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
79pub 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
101pub 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
112pub 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
137pub 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
146pub 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
227pub 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
383async 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
432pub(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(¤t) == url_origin(&next);
487 current = next;
488 }
489 unreachable!("bounded redirect loop always returns")
490}
491
492fn 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
689pub 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
716pub 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
789pub 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
802pub 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
812pub 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}