1use std::error::Error as _;
7use std::fmt;
8use std::io;
9use std::path::PathBuf;
10use std::pin::Pin;
11use std::str::FromStr;
12use std::task::{Context, Poll};
13use std::time::{Duration, SystemTimeError};
14
15use futures::{StreamExt, TryStreamExt};
16use reqwest_retry::Retryable;
17use reqwest_retry::policies::ExponentialBackoff;
18use serde::Deserialize;
19use thiserror::Error;
20use tokio::io::{AsyncRead, ReadBuf};
21use tokio_util::compat::FuturesAsyncReadCompatExt;
22use url::Url;
23use uv_client::retryable_on_request_failure;
24use uv_distribution_filename::SourceDistExtension;
25
26use uv_cache::{Cache, CacheBucket, CacheEntry, Error as CacheError};
27use uv_client::{BaseClient, RetriableError, fetch_with_url_fallback};
28use uv_extract::{Error as ExtractError, stream};
29use uv_pep440::{Version, VersionSpecifier, VersionSpecifiers};
30use uv_platform::Platform;
31use uv_redacted::DisplaySafeUrl;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub enum Binary {
36 Ruff,
37 Uv,
38}
39
40impl Binary {
41 pub fn default_constraints(&self) -> VersionSpecifiers {
46 match self {
47 Self::Ruff => [
49 VersionSpecifier::greater_than_equal_version(Version::new([0, 15])),
50 VersionSpecifier::less_than_version(Version::new([0, 16])),
51 ]
52 .into_iter()
53 .collect(),
54 Self::Uv => VersionSpecifiers::empty(),
55 }
56 }
57
58 pub fn name(&self) -> &'static str {
62 match self {
63 Self::Ruff => "ruff",
64 Self::Uv => "uv",
65 }
66 }
67
68 pub fn download_urls(
70 &self,
71 version: &Version,
72 platform: &str,
73 format: ArchiveFormat,
74 ) -> Result<Vec<DisplaySafeUrl>, Error> {
75 match self {
76 Self::Ruff => {
77 let suffix = format!("{version}/ruff-{platform}.{}", format.extension());
78 let canonical = format!("{RUFF_GITHUB_URL_PREFIX}{suffix}");
79 let mirror = format!("{RUFF_DEFAULT_MIRROR}{suffix}");
80 Ok(vec![
81 DisplaySafeUrl::parse(&mirror).map_err(|err| Error::UrlParse {
82 url: mirror,
83 source: err,
84 })?,
85 DisplaySafeUrl::parse(&canonical).map_err(|err| Error::UrlParse {
86 url: canonical,
87 source: err,
88 })?,
89 ])
90 }
91 Self::Uv => {
92 let canonical = format!(
93 "{UV_GITHUB_URL_PREFIX}{version}/uv-{platform}.{}",
94 format.extension()
95 );
96 Ok(vec![DisplaySafeUrl::parse(&canonical).map_err(|err| {
97 Error::UrlParse {
98 url: canonical,
99 source: err,
100 }
101 })?])
102 }
103 }
104 }
105
106 fn manifest_urls(self) -> Vec<DisplaySafeUrl> {
108 let name = self.name();
109 match self {
110 Self::Ruff => vec![
112 DisplaySafeUrl::parse(&format!("{VERSIONS_MANIFEST_MIRROR}/{name}.ndjson"))
113 .unwrap(),
114 DisplaySafeUrl::parse(&format!("{VERSIONS_MANIFEST_URL}/{name}.ndjson")).unwrap(),
115 ],
116 Self::Uv => vec![
117 DisplaySafeUrl::parse(&format!("{VERSIONS_MANIFEST_MIRROR}/{name}.ndjson"))
118 .unwrap(),
119 DisplaySafeUrl::parse(&format!("{VERSIONS_MANIFEST_URL}/{name}.ndjson")).unwrap(),
120 ],
121 }
122 }
123
124 fn mirror_urls(self, canonical_url: DisplaySafeUrl) -> Vec<DisplaySafeUrl> {
127 match self {
128 Self::Ruff => {
129 if let Some(suffix) = canonical_url.as_str().strip_prefix(RUFF_GITHUB_URL_PREFIX) {
130 let mirror_str = format!("{RUFF_DEFAULT_MIRROR}{suffix}");
131 if let Ok(mirror_url) = DisplaySafeUrl::parse(&mirror_str) {
132 return vec![mirror_url, canonical_url];
133 }
134 }
135 vec![canonical_url]
136 }
137 Self::Uv => vec![canonical_url],
138 }
139 }
140
141 pub fn executable(&self) -> String {
143 format!("{}{}", self.name(), std::env::consts::EXE_SUFFIX)
144 }
145}
146
147impl fmt::Display for Binary {
148 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149 f.write_str(self.name())
150 }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum ArchiveFormat {
156 Zip,
157 TarGz,
158}
159
160impl ArchiveFormat {
161 pub fn extension(&self) -> &'static str {
163 match self {
164 Self::Zip => "zip",
165 Self::TarGz => "tar.gz",
166 }
167 }
168}
169
170impl From<ArchiveFormat> for SourceDistExtension {
171 fn from(val: ArchiveFormat) -> Self {
172 match val {
173 ArchiveFormat::Zip => Self::Zip,
174 ArchiveFormat::TarGz => Self::TarGz,
175 }
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum BinVersion {
182 Default,
184 Latest,
186 Pinned(Version),
188 Constraint(uv_pep440::VersionSpecifiers),
190}
191
192impl FromStr for BinVersion {
193 type Err = uv_pep440::VersionSpecifiersParseError;
194
195 fn from_str(s: &str) -> Result<Self, Self::Err> {
196 if s.eq_ignore_ascii_case("latest") {
197 return Ok(Self::Latest);
198 }
199 if let Ok(version) = Version::from_str(s) {
201 return Ok(Self::Pinned(version));
202 }
203 let specifiers = uv_pep440::VersionSpecifiers::from_str(s)?;
205 Ok(Self::Constraint(specifiers))
206 }
207}
208
209impl fmt::Display for BinVersion {
210 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211 match self {
212 Self::Default => f.write_str("default"),
213 Self::Latest => f.write_str("latest"),
214 Self::Pinned(version) => write!(f, "{version}"),
215 Self::Constraint(specifiers) => write!(f, "{specifiers}"),
216 }
217 }
218}
219
220const RUFF_GITHUB_URL_PREFIX: &str = "https://github.com/astral-sh/ruff/releases/download/";
222
223const UV_GITHUB_URL_PREFIX: &str = "https://github.com/astral-sh/uv/releases/download/";
225
226const RUFF_DEFAULT_MIRROR: &str = "https://releases.astral.sh/github/ruff/releases/download/";
231
232const VERSIONS_MANIFEST_URL: &str = "https://raw.githubusercontent.com/astral-sh/versions/main/v1";
234
235const VERSIONS_MANIFEST_MIRROR: &str = "https://releases.astral.sh/github/versions/main/v1";
237
238#[derive(Debug, Deserialize)]
240struct BinVersionInfo {
241 #[serde(deserialize_with = "deserialize_version")]
242 version: Version,
243 date: jiff::Timestamp,
244 artifacts: Vec<BinArtifact>,
245}
246
247fn deserialize_version<'de, D>(deserializer: D) -> Result<Version, D::Error>
248where
249 D: serde::Deserializer<'de>,
250{
251 let s = String::deserialize(deserializer)?;
252 Version::from_str(&s).map_err(serde::de::Error::custom)
253}
254
255#[derive(Debug, Deserialize)]
257struct BinArtifact {
258 platform: String,
259 url: String,
260 archive_format: String,
261}
262
263#[derive(Debug)]
265pub struct ResolvedVersion {
266 pub version: Version,
268 pub artifact_urls: Vec<DisplaySafeUrl>,
270 pub archive_format: ArchiveFormat,
272}
273
274impl ResolvedVersion {
275 pub fn from_version(binary: Binary, version: Version) -> Result<Self, Error> {
278 let platform = Platform::from_env()?;
279 let platform_name = platform.as_cargo_dist_triple();
280 let archive_format = if platform.os.is_windows() {
281 ArchiveFormat::Zip
282 } else {
283 ArchiveFormat::TarGz
284 };
285 let artifact_urls = binary.download_urls(&version, &platform_name, archive_format)?;
286 Ok(Self {
287 version,
288 artifact_urls,
289 archive_format,
290 })
291 }
292}
293
294#[derive(Debug, Error)]
296pub enum Error {
297 #[error("Failed to download from: {url}")]
298 Download {
299 url: DisplaySafeUrl,
300 #[source]
301 source: reqwest_middleware::Error,
302 },
303
304 #[error("Failed to read from: {url}")]
305 Stream {
306 url: DisplaySafeUrl,
307 #[source]
308 source: reqwest::Error,
309 },
310
311 #[error("Failed to parse URL: {url}")]
312 UrlParse {
313 url: String,
314 #[source]
315 source: uv_redacted::DisplaySafeUrlError,
316 },
317
318 #[error("Failed to extract archive")]
319 Extract {
320 #[source]
321 source: ExtractError,
322 },
323
324 #[error("Binary not found in archive at expected location: {expected}")]
325 BinaryNotFound { expected: PathBuf },
326
327 #[error(transparent)]
328 Io(#[from] std::io::Error),
329
330 #[error(transparent)]
331 Cache(#[from] CacheError),
332
333 #[error("Failed to detect platform")]
334 Platform(#[from] uv_platform::Error),
335
336 #[error(
337 "Request failed after {retries} {subject} in {duration:.1}s",
338 subject = if *retries > 1 { "retries" } else { "retry" },
339 duration = duration.as_secs_f32()
340 )]
341 RetriedError {
342 #[source]
343 err: Box<Self>,
344 retries: u32,
345 duration: Duration,
346 },
347
348 #[error("Failed to fetch version manifest from: {url}")]
349 ManifestFetch {
350 url: String,
351 #[source]
352 source: reqwest_middleware::Error,
353 },
354
355 #[error("Failed to parse version manifest")]
356 ManifestParse(#[from] serde_json::Error),
357
358 #[error("Invalid UTF-8 in version manifest")]
359 ManifestUtf8(#[from] std::str::Utf8Error),
360
361 #[error("No version of {binary} found matching `{constraints}` for platform `{platform}`")]
362 NoMatchingVersion {
363 binary: Binary,
364 constraints: uv_pep440::VersionSpecifiers,
365 platform: String,
366 },
367
368 #[error("No version of {binary} found for platform `{platform}`")]
369 NoVersionForPlatform { binary: Binary, platform: String },
370
371 #[error("No artifact found for {binary} {version} on platform {platform}")]
372 NoArtifactForPlatform {
373 binary: Binary,
374 version: String,
375 platform: String,
376 },
377
378 #[error("Unsupported archive format: {0}")]
379 UnsupportedArchiveFormat(String),
380
381 #[error(transparent)]
382 SystemTime(#[from] SystemTimeError),
383}
384
385impl RetriableError for Error {
386 fn retries(&self) -> u32 {
387 if let Self::RetriedError { retries, .. } = self {
388 return *retries;
389 }
390 0
391 }
392
393 fn should_try_next_url(&self) -> bool {
397 match self {
398 Self::Download { .. }
399 | Self::ManifestFetch { .. }
400 | Self::ManifestParse(..)
401 | Self::ManifestUtf8(..) => true,
402 Self::Stream { .. } => true,
403 Self::RetriedError { err, .. } => err.should_try_next_url(),
404 err => {
405 let mut source = err.source();
407 while let Some(err) = source {
408 if let Some(io_err) = err.downcast_ref::<io::Error>() {
409 if io_err
410 .get_ref()
411 .and_then(|e| e.downcast_ref::<Self>() as Option<&Self>)
412 .is_some_and(|e| {
413 matches!(e, Self::Stream { .. } | Self::Download { .. })
414 })
415 {
416 return true;
417 }
418 }
419 source = err.source();
420 }
421 retryable_on_request_failure(err) == Some(Retryable::Transient)
423 }
424 }
425 }
426
427 fn into_retried(self, retries: u32, duration: Duration) -> Self {
428 Self::RetriedError {
429 err: Box::new(self),
430 retries,
431 duration,
432 }
433 }
434}
435
436pub async fn find_matching_version(
446 binary: Binary,
447 constraints: Option<&uv_pep440::VersionSpecifiers>,
448 exclude_newer: Option<jiff::Timestamp>,
449 client: &BaseClient,
450 retry_policy: &ExponentialBackoff,
451) -> Result<ResolvedVersion, Error> {
452 let platform = Platform::from_env()?;
453 let platform_name = platform.as_cargo_dist_triple();
454
455 fetch_with_url_fallback(
456 &binary.manifest_urls(),
457 *retry_policy,
458 &format!("manifest for `{binary}`"),
459 |url| {
460 fetch_and_find_matching_version(
461 binary,
462 constraints,
463 exclude_newer,
464 &platform_name,
465 url,
466 client,
467 )
468 },
469 )
470 .await
471}
472
473async fn fetch_and_find_matching_version(
478 binary: Binary,
479 constraints: Option<&uv_pep440::VersionSpecifiers>,
480 exclude_newer: Option<jiff::Timestamp>,
481 platform_name: &str,
482 manifest_url: DisplaySafeUrl,
483 client: &BaseClient,
484) -> Result<ResolvedVersion, Error> {
485 let response = client
486 .for_host(&manifest_url)
487 .get(Url::from(manifest_url.clone()))
488 .send()
489 .await
490 .map_err(|source| Error::ManifestFetch {
491 url: manifest_url.to_string(),
492 source,
493 })?;
494
495 let response = response
496 .error_for_status()
497 .map_err(|err| Error::ManifestFetch {
498 url: manifest_url.to_string(),
499 source: reqwest_middleware::Error::Reqwest(err),
500 })?;
501
502 let parse_and_check = |line: &[u8]| -> Result<Option<ResolvedVersion>, Error> {
504 let line_str = std::str::from_utf8(line)?;
505 if line_str.trim().is_empty() {
506 return Ok(None);
507 }
508 let version_info: BinVersionInfo = serde_json::from_str(line_str)?;
509 Ok(check_version_match(
510 binary,
511 &version_info,
512 constraints,
513 exclude_newer,
514 platform_name,
515 ))
516 };
517
518 let mut stream = response.bytes_stream();
520 let mut buffer = Vec::new();
521
522 while let Some(chunk) = stream.next().await {
523 let chunk = chunk.map_err(|err| Error::ManifestFetch {
524 url: manifest_url.to_string(),
525 source: reqwest_middleware::Error::Reqwest(err),
526 })?;
527 buffer.extend_from_slice(&chunk);
528
529 while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
531 let line = &buffer[..newline_pos];
532 let result = parse_and_check(line)?;
533 buffer.drain(..=newline_pos);
534
535 if let Some(resolved) = result {
536 return Ok(resolved);
537 }
538 }
539 }
540
541 if let Some(resolved) = parse_and_check(&buffer)? {
543 return Ok(resolved);
544 }
545
546 match constraints {
548 Some(constraints) => Err(Error::NoMatchingVersion {
549 binary,
550 constraints: constraints.clone(),
551 platform: platform_name.to_string(),
552 }),
553 None => Err(Error::NoVersionForPlatform {
554 binary,
555 platform: platform_name.to_string(),
556 }),
557 }
558}
559
560fn check_version_match(
565 binary: Binary,
566 version_info: &BinVersionInfo,
567 constraints: Option<&uv_pep440::VersionSpecifiers>,
568 exclude_newer: Option<jiff::Timestamp>,
569 platform_name: &str,
570) -> Option<ResolvedVersion> {
571 if let Some(cutoff) = exclude_newer
573 && version_info.date > cutoff
574 {
575 return None;
576 }
577
578 if let Some(constraints) = constraints
580 && !constraints.contains(&version_info.version)
581 {
582 return None;
583 }
584
585 for artifact in &version_info.artifacts {
588 if artifact.platform != platform_name {
589 continue;
590 }
591
592 let Ok(canonical_url) = DisplaySafeUrl::parse(&artifact.url) else {
593 continue;
594 };
595
596 let archive_format = match artifact.archive_format.as_str() {
597 "tar.gz" => ArchiveFormat::TarGz,
598 "zip" => ArchiveFormat::Zip,
599 _ => continue,
600 };
601
602 return Some(ResolvedVersion {
603 version: version_info.version.clone(),
604 artifact_urls: binary.mirror_urls(canonical_url),
605 archive_format,
606 });
607 }
608
609 None
610}
611
612pub async fn bin_install(
614 binary: Binary,
615 resolved: &ResolvedVersion,
616 client: &BaseClient,
617 retry_policy: &ExponentialBackoff,
618 cache: &Cache,
619 reporter: &dyn Reporter,
620) -> Result<PathBuf, Error> {
621 let platform = Platform::from_env()?;
622 let platform_name = platform.as_cargo_dist_triple();
623
624 bin_install_from_urls(
625 binary,
626 &resolved.version,
627 &resolved.artifact_urls,
628 resolved.archive_format,
629 &platform_name,
630 client,
631 retry_policy,
632 cache,
633 reporter,
634 )
635 .await
636}
637
638async fn bin_install_from_urls(
640 binary: Binary,
641 version: &Version,
642 download_urls: &[DisplaySafeUrl],
643 format: ArchiveFormat,
644 platform_name: &str,
645 client: &BaseClient,
646 retry_policy: &ExponentialBackoff,
647 cache: &Cache,
648 reporter: &dyn Reporter,
649) -> Result<PathBuf, Error> {
650 let cache_entry = CacheEntry::new(
651 cache
652 .bucket(CacheBucket::Binaries)
653 .join(binary.name())
654 .join(version.to_string())
655 .join(platform_name),
656 binary.executable(),
657 );
658
659 let _lock = cache_entry.with_file(".lock").lock().await?;
661 if cache_entry.path().exists() {
662 return Ok(cache_entry.into_path_buf());
663 }
664
665 let cache_dir = cache_entry.dir();
666 fs_err::tokio::create_dir_all(&cache_dir).await?;
667
668 let path = fetch_with_url_fallback(
669 download_urls,
670 *retry_policy,
671 &format!("`{binary}`"),
672 |url| {
673 download_and_unpack(
674 binary,
675 version,
676 client,
677 cache,
678 reporter,
679 platform_name,
680 format,
681 url,
682 &cache_entry,
683 )
684 },
685 )
686 .await?;
687
688 #[cfg(unix)]
690 {
691 use std::fs::Permissions;
692 use std::os::unix::fs::PermissionsExt;
693 let permissions = fs_err::tokio::metadata(&path).await?.permissions();
694 if permissions.mode() & 0o111 != 0o111 {
695 fs_err::tokio::set_permissions(
696 &path,
697 Permissions::from_mode(permissions.mode() | 0o111),
698 )
699 .await?;
700 }
701 }
702
703 Ok(path)
704}
705
706async fn download_and_unpack(
710 binary: Binary,
711 version: &Version,
712 client: &BaseClient,
713 cache: &Cache,
714 reporter: &dyn Reporter,
715 platform_name: &str,
716 format: ArchiveFormat,
717 download_url: DisplaySafeUrl,
718 cache_entry: &CacheEntry,
719) -> Result<PathBuf, Error> {
720 let temp_dir = tempfile::tempdir_in(cache.bucket(CacheBucket::Binaries))?;
722
723 let response = client
724 .for_host(&download_url)
725 .get(Url::from(download_url.clone()))
726 .send()
727 .await
728 .map_err(|err| Error::Download {
729 url: download_url.clone(),
730 source: err,
731 })?;
732
733 let inner_retries = response
734 .extensions()
735 .get::<reqwest_retry::RetryCount>()
736 .map(|retries| retries.value());
737
738 if let Err(status_error) = response.error_for_status_ref() {
739 let err = Error::Download {
740 url: download_url.clone(),
741 source: reqwest_middleware::Error::from(status_error),
742 };
743 if let Some(retries) = inner_retries {
744 return Err(Error::RetriedError {
745 err: Box::new(err),
746 retries,
747 duration: Duration::default(),
749 });
750 }
751 return Err(err);
752 }
753
754 let size = response
756 .headers()
757 .get(reqwest::header::CONTENT_LENGTH)
758 .and_then(|val| val.to_str().ok())
759 .and_then(|val| val.parse::<u64>().ok());
760
761 let reader = response
763 .bytes_stream()
764 .map_err(|err| {
765 std::io::Error::other(Error::Stream {
766 url: download_url.clone(),
767 source: err,
768 })
769 })
770 .into_async_read()
771 .compat();
772
773 let id = reporter.on_download_start(binary.name(), version, size);
774 let mut progress_reader = ProgressReader::new(reader, id, reporter);
775 stream::archive(
776 &download_url,
777 &mut progress_reader,
778 format.into(),
779 temp_dir.path(),
780 )
781 .await
782 .map_err(|e| Error::Extract { source: e })?;
783 reporter.on_download_complete(id);
784
785 let extracted_binary = match format {
787 ArchiveFormat::Zip => {
788 temp_dir.path().join(binary.executable())
790 }
791 ArchiveFormat::TarGz => {
792 temp_dir
794 .path()
795 .join(format!("{}-{platform_name}", binary.name()))
796 .join(binary.executable())
797 }
798 };
799
800 if !extracted_binary.exists() {
801 return Err(Error::BinaryNotFound {
802 expected: extracted_binary,
803 });
804 }
805
806 fs_err::tokio::rename(&extracted_binary, cache_entry.path()).await?;
808
809 Ok(cache_entry.path().to_path_buf())
810}
811
812pub trait Reporter: Send + Sync {
814 fn on_download_start(&self, name: &str, version: &Version, size: Option<u64>) -> usize;
816 fn on_download_progress(&self, id: usize, inc: u64);
818 fn on_download_complete(&self, id: usize);
820}
821
822struct ProgressReader<'a, R> {
824 reader: R,
825 index: usize,
826 reporter: &'a dyn Reporter,
827}
828
829impl<'a, R> ProgressReader<'a, R> {
830 fn new(reader: R, index: usize, reporter: &'a dyn Reporter) -> Self {
832 Self {
833 reader,
834 index,
835 reporter,
836 }
837 }
838}
839
840impl<R> AsyncRead for ProgressReader<'_, R>
841where
842 R: AsyncRead + Unpin,
843{
844 fn poll_read(
845 mut self: Pin<&mut Self>,
846 cx: &mut Context<'_>,
847 buf: &mut ReadBuf<'_>,
848 ) -> Poll<std::io::Result<()>> {
849 Pin::new(&mut self.as_mut().reader)
850 .poll_read(cx, buf)
851 .map_ok(|()| {
852 self.reporter
853 .on_download_progress(self.index, buf.filled().len() as u64);
854 })
855 }
856}
857
858#[cfg(test)]
859mod tests {
860 use serde_json::json;
861 use std::io::Write;
862 use uv_client::{BaseClientBuilder, fetch_with_url_fallback, retryable_on_request_failure};
863 use uv_redacted::DisplaySafeUrl;
864 use wiremock::matchers::{method, path};
865 use wiremock::{Mock, MockServer, ResponseTemplate};
866
867 use super::*;
868
869 async fn spawn_manifest_server(response: ResponseTemplate) -> (DisplaySafeUrl, MockServer) {
870 let server = MockServer::start().await;
871 Mock::given(method("GET"))
872 .and(path("/uv.ndjson"))
873 .respond_with(response)
874 .mount(&server)
875 .await;
876
877 (
878 DisplaySafeUrl::parse(&format!("{}/uv.ndjson", server.uri())).unwrap(),
879 server,
880 )
881 }
882
883 fn manifest_response(body: &str) -> ResponseTemplate {
884 ResponseTemplate::new(200).set_body_raw(body.to_owned(), "application/x-ndjson")
885 }
886
887 fn not_found_response() -> ResponseTemplate {
888 ResponseTemplate::new(404)
889 }
890
891 fn uv_manifest_line(version: &str, platform: &str) -> String {
892 let extension = if cfg!(windows) { "zip" } else { "tar.gz" };
893 let url = format!(
894 "https://github.com/astral-sh/uv/releases/download/{version}/uv-{platform}.{extension}"
895 );
896
897 format!(
898 "{}\n",
899 json!({
900 "version": version,
901 "date": "2025-01-01T00:00:00Z",
902 "artifacts": [{
903 "platform": platform,
904 "url": url,
905 "archive_format": extension,
906 }],
907 })
908 )
909 }
910
911 async fn resolve_version_from_manifest_urls(
912 urls: &[DisplaySafeUrl],
913 constraints: Option<&VersionSpecifiers>,
914 ) -> Result<ResolvedVersion, Error> {
915 let platform = Platform::from_env().unwrap();
916 let platform_name = platform.as_cargo_dist_triple();
917 let client_builder = BaseClientBuilder::default().retries(0);
918 let retry_policy = client_builder.retry_policy();
919 let client = client_builder.build().expect("failed to build base client");
920
921 fetch_with_url_fallback(urls, retry_policy, "manifest for `uv`", |url| {
922 fetch_and_find_matching_version(
923 Binary::Uv,
924 constraints,
925 None,
926 &platform_name,
927 url,
928 &client,
929 )
930 })
931 .await
932 }
933
934 #[test]
935 fn test_uv_download_urls() {
936 let urls = Binary::Uv
937 .download_urls(
938 &Version::new([0, 6, 0]),
939 "x86_64-unknown-linux-gnu",
940 ArchiveFormat::TarGz,
941 )
942 .expect("uv download URLs should be valid");
943
944 let urls = urls
945 .into_iter()
946 .map(|url| url.to_string())
947 .collect::<Vec<_>>();
948 assert_eq!(
949 urls,
950 vec![
951 "https://github.com/astral-sh/uv/releases/download/0.6.0/uv-x86_64-unknown-linux-gnu.tar.gz"
952 .to_string(),
953 ]
954 );
955 }
956
957 #[tokio::test]
958 async fn test_manifest_falls_back_on_404() {
959 let platform = Platform::from_env().unwrap();
960 let platform_name = platform.as_cargo_dist_triple();
961 let (mirror_url, mirror_server) = spawn_manifest_server(not_found_response()).await;
962 let (canonical_url, canonical_server) = spawn_manifest_server(manifest_response(
963 &uv_manifest_line("1.2.3", &platform_name),
964 ))
965 .await;
966
967 let resolved = resolve_version_from_manifest_urls(&[mirror_url, canonical_url], None)
968 .await
969 .expect("404 from mirror should fall back to canonical manifest");
970
971 assert_eq!(resolved.version, Version::new([1, 2, 3]));
972 assert_eq!(mirror_server.received_requests().await.unwrap().len(), 1);
973 assert_eq!(canonical_server.received_requests().await.unwrap().len(), 1);
974 }
975
976 #[tokio::test]
977 async fn test_manifest_falls_back_on_parse_error() {
978 let platform = Platform::from_env().unwrap();
979 let platform_name = platform.as_cargo_dist_triple();
980 let (mirror_url, mirror_server) =
981 spawn_manifest_server(manifest_response("{not json}\n")).await;
982 let (canonical_url, canonical_server) = spawn_manifest_server(manifest_response(
983 &uv_manifest_line("1.2.3", &platform_name),
984 ))
985 .await;
986
987 let resolved = resolve_version_from_manifest_urls(&[mirror_url, canonical_url], None)
988 .await
989 .expect("parse failure from mirror should fall back to canonical manifest");
990
991 assert_eq!(resolved.version, Version::new([1, 2, 3]));
992 assert_eq!(mirror_server.received_requests().await.unwrap().len(), 1);
993 assert_eq!(canonical_server.received_requests().await.unwrap().len(), 1);
994 }
995
996 #[tokio::test]
997 async fn test_manifest_no_matching_version_does_not_fallback() {
998 let platform = Platform::from_env().unwrap();
999 let platform_name = platform.as_cargo_dist_triple();
1000 let (mirror_url, mirror_server) = spawn_manifest_server(manifest_response(
1001 &uv_manifest_line("1.2.3", &platform_name),
1002 ))
1003 .await;
1004 let (canonical_url, canonical_server) = spawn_manifest_server(manifest_response(
1005 &uv_manifest_line("9.9.9", &platform_name),
1006 ))
1007 .await;
1008 let constraints =
1009 VersionSpecifiers::from(VersionSpecifier::equals_version(Version::new([9, 9, 9])));
1010
1011 let err =
1012 resolve_version_from_manifest_urls(&[mirror_url, canonical_url], Some(&constraints))
1013 .await
1014 .expect_err("no matching version should not fall back to canonical manifest");
1015
1016 assert!(matches!(err, Error::NoMatchingVersion { .. }));
1017 assert_eq!(mirror_server.received_requests().await.unwrap().len(), 1);
1018 assert_eq!(canonical_server.received_requests().await.unwrap().len(), 0);
1019 }
1020
1021 #[tokio::test]
1027 async fn test_non_retryable_stream_error_triggers_url_fallback() {
1028 use futures::TryStreamExt;
1029
1030 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1031 let addr = listener.local_addr().unwrap();
1032
1033 std::thread::spawn(move || {
1034 let (mut stream, _) = listener.accept().unwrap();
1035 let mut buf = [0u8; 4096];
1036 let _ = std::io::Read::read(&mut stream, &mut buf);
1037 stream
1038 .write_all(
1039 b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nZZZ\r\nhello\r\n0\r\n\r\n",
1040 )
1041 .unwrap();
1042 });
1043
1044 let url = DisplaySafeUrl::parse(&format!("http://{addr}/ruff.tar.gz")).unwrap();
1045 let client = BaseClientBuilder::default()
1046 .build()
1047 .expect("failed to build base client");
1048 let response = client
1049 .for_host(&url)
1050 .get(Url::from(url.clone()))
1051 .send()
1052 .await
1053 .unwrap();
1054
1055 let reqwest_err = response.bytes_stream().try_next().await.unwrap_err();
1056 assert!(reqwest_err.is_body() || reqwest_err.is_decode());
1057
1058 let err = Error::Extract {
1059 source: ExtractError::Io(io::Error::other(Error::Stream {
1060 url,
1061 source: reqwest_err,
1062 })),
1063 };
1064
1065 assert!(retryable_on_request_failure(&err).is_none());
1066 assert!(
1067 err.should_try_next_url(),
1068 "non-retryable streaming error should still trigger URL fallback, got: {err}"
1069 );
1070 }
1071}