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::LegacySourceDistExtension;
25use uv_distribution_filename::SourceDistExtension;
26use uv_static::{astral_mirror_base_url, astral_mirror_url_from_env, custom_astral_mirror_url};
27
28use uv_cache::{Cache, CacheBucket, CacheEntry, Error as CacheError};
29use uv_client::{BaseClient, RetriableError, fetch_with_url_fallback};
30use uv_extract::{Error as ExtractError, stream};
31use uv_pep440::{Version, VersionSpecifier, VersionSpecifiers};
32use uv_platform::Platform;
33use uv_redacted::DisplaySafeUrl;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum Binary {
38 Ruff,
39 Ty,
40 Uv,
41}
42
43impl Binary {
44 pub fn default_constraints(&self) -> VersionSpecifiers {
49 match self {
50 Self::Ruff => [
52 VersionSpecifier::greater_than_equal_version(Version::new([0, 15])),
53 VersionSpecifier::less_than_version(Version::new([0, 16])),
54 ]
55 .into_iter()
56 .collect(),
57 Self::Ty => [
58 VersionSpecifier::greater_than_equal_version(Version::new([0, 0])),
59 VersionSpecifier::less_than_version(Version::new([0, 1])),
60 ]
61 .into_iter()
62 .collect(),
63 Self::Uv => VersionSpecifiers::empty(),
64 }
65 }
66
67 fn name(self) -> &'static str {
71 match self {
72 Self::Ruff => "ruff",
73 Self::Ty => "ty",
74 Self::Uv => "uv",
75 }
76 }
77
78 fn download_urls(
80 self,
81 version: &Version,
82 platform: &str,
83 format: ArchiveFormat,
84 ) -> Result<Vec<DisplaySafeUrl>, Error> {
85 let custom_astral_mirror = astral_mirror_url_from_env();
86 self.download_urls_with_astral_mirror(
87 version,
88 platform,
89 format,
90 custom_astral_mirror.as_deref(),
91 )
92 }
93
94 fn download_urls_with_astral_mirror(
95 self,
96 version: &Version,
97 platform: &str,
98 format: ArchiveFormat,
99 astral_mirror_url: Option<&str>,
100 ) -> Result<Vec<DisplaySafeUrl>, Error> {
101 let astral_mirror_url = custom_astral_mirror_url(astral_mirror_url);
102 match self {
103 Self::Ruff => {
104 let suffix = format!("{version}/ruff-{platform}.{}", format.extension());
105 let mirror_base = astral_mirror_base_url(astral_mirror_url);
106 let mirror = format!("{mirror_base}{RUFF_MIRROR_SUFFIX}{suffix}");
107 let mut urls = vec![parse_url(mirror)?];
108 if astral_mirror_url.is_none() {
110 let canonical = format!("{RUFF_GITHUB_URL_PREFIX}{suffix}");
111 urls.push(parse_url(canonical)?);
112 }
113 Ok(urls)
114 }
115 Self::Ty => {
116 let suffix = format!("{version}/ty-{platform}.{}", format.extension());
117 let mirror_base = astral_mirror_base_url(astral_mirror_url);
118 let mirror = format!("{mirror_base}{TY_MIRROR_SUFFIX}{suffix}");
119 let mut urls = vec![parse_url(mirror)?];
120 if astral_mirror_url.is_none() {
122 let canonical = format!("{TY_GITHUB_URL_PREFIX}{suffix}");
123 urls.push(parse_url(canonical)?);
124 }
125 Ok(urls)
126 }
127 Self::Uv => {
128 let canonical = format!(
129 "{UV_GITHUB_URL_PREFIX}{version}/uv-{platform}.{}",
130 format.extension()
131 );
132 Ok(vec![parse_url(canonical)?])
133 }
134 }
135 }
136
137 fn manifest_urls(self) -> Result<Vec<DisplaySafeUrl>, Error> {
139 let custom_astral_mirror = astral_mirror_url_from_env();
140 self.manifest_urls_with_astral_mirror(custom_astral_mirror.as_deref())
141 }
142
143 fn manifest_urls_with_astral_mirror(
144 self,
145 astral_mirror_url: Option<&str>,
146 ) -> Result<Vec<DisplaySafeUrl>, Error> {
147 let astral_mirror_url = custom_astral_mirror_url(astral_mirror_url);
148 let name = self.name();
149 let mirror_base = astral_mirror_base_url(astral_mirror_url);
150 let mirror = format!("{mirror_base}{VERSIONS_MANIFEST_MIRROR_SUFFIX}/{name}.ndjson");
151 let mut urls = vec![parse_url(mirror)?];
152 if astral_mirror_url.is_none() {
154 let canonical = format!("{VERSIONS_MANIFEST_URL}/{name}.ndjson");
155 urls.push(parse_url(canonical)?);
156 }
157 Ok(urls)
158 }
159
160 fn mirror_urls(self, canonical_url: DisplaySafeUrl) -> Result<Vec<DisplaySafeUrl>, Error> {
163 let custom_astral_mirror = astral_mirror_url_from_env();
164 self.mirror_urls_with_astral_mirror(canonical_url, custom_astral_mirror.as_deref())
165 }
166
167 fn mirror_urls_with_astral_mirror(
168 self,
169 canonical_url: DisplaySafeUrl,
170 astral_mirror_url: Option<&str>,
171 ) -> Result<Vec<DisplaySafeUrl>, Error> {
172 let astral_mirror_url = custom_astral_mirror_url(astral_mirror_url);
173 match self {
174 Self::Ruff => {
175 if let Some(suffix) = canonical_url.as_str().strip_prefix(RUFF_GITHUB_URL_PREFIX) {
176 let mirror_base = astral_mirror_base_url(astral_mirror_url);
177 let mirror = format!("{mirror_base}{RUFF_MIRROR_SUFFIX}{suffix}");
178 let mirror_url = parse_url(mirror)?;
179 if astral_mirror_url.is_some() {
180 return Ok(vec![mirror_url]);
181 }
182 return Ok(vec![mirror_url, canonical_url]);
183 }
184 Ok(vec![canonical_url])
185 }
186 Self::Ty => {
187 if let Some(suffix) = canonical_url.as_str().strip_prefix(TY_GITHUB_URL_PREFIX) {
188 let mirror_base = astral_mirror_base_url(astral_mirror_url);
189 let mirror = format!("{mirror_base}{TY_MIRROR_SUFFIX}{suffix}");
190 let mirror_url = parse_url(mirror)?;
191 if astral_mirror_url.is_some() {
192 return Ok(vec![mirror_url]);
193 }
194 return Ok(vec![mirror_url, canonical_url]);
195 }
196 Ok(vec![canonical_url])
197 }
198 Self::Uv => Ok(vec![canonical_url]),
199 }
200 }
201
202 fn executable(self) -> String {
204 format!("{}{}", self.name(), std::env::consts::EXE_SUFFIX)
205 }
206}
207
208impl fmt::Display for Binary {
209 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210 f.write_str(self.name())
211 }
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216enum ArchiveFormat {
217 Zip,
218 TarGz,
219}
220
221impl ArchiveFormat {
222 fn extension(self) -> &'static str {
224 match self {
225 Self::Zip => "zip",
226 Self::TarGz => "tar.gz",
227 }
228 }
229}
230
231impl From<ArchiveFormat> for SourceDistExtension {
232 fn from(val: ArchiveFormat) -> Self {
233 match val {
234 ArchiveFormat::Zip => Self::Legacy(LegacySourceDistExtension::Zip),
235 ArchiveFormat::TarGz => Self::TarGz,
236 }
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum BinVersion {
243 Default,
245 Latest,
247 Pinned(Version),
249 Constraint(uv_pep440::VersionSpecifiers),
251}
252
253impl FromStr for BinVersion {
254 type Err = uv_pep440::VersionSpecifiersParseError;
255
256 fn from_str(s: &str) -> Result<Self, Self::Err> {
257 if s.eq_ignore_ascii_case("latest") {
258 return Ok(Self::Latest);
259 }
260 if let Ok(version) = Version::from_str(s) {
262 return Ok(Self::Pinned(version));
263 }
264 let specifiers = uv_pep440::VersionSpecifiers::from_str(s)?;
266 Ok(Self::Constraint(specifiers))
267 }
268}
269
270impl fmt::Display for BinVersion {
271 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272 match self {
273 Self::Default => f.write_str("default"),
274 Self::Latest => f.write_str("latest"),
275 Self::Pinned(version) => write!(f, "{version}"),
276 Self::Constraint(specifiers) => write!(f, "{specifiers}"),
277 }
278 }
279}
280
281const RUFF_GITHUB_URL_PREFIX: &str = "https://github.com/astral-sh/ruff/releases/download/";
283
284const TY_GITHUB_URL_PREFIX: &str = "https://github.com/astral-sh/ty/releases/download/";
286
287const UV_GITHUB_URL_PREFIX: &str = "https://github.com/astral-sh/uv/releases/download/";
289
290const RUFF_MIRROR_SUFFIX: &str = "/github/ruff/releases/download/";
292
293const TY_MIRROR_SUFFIX: &str = "/github/ty/releases/download/";
295
296const VERSIONS_MANIFEST_MIRROR_SUFFIX: &str = "/github/versions/main/v1";
298
299const VERSIONS_MANIFEST_URL: &str = "https://raw.githubusercontent.com/astral-sh/versions/main/v1";
301
302fn parse_url(url: String) -> Result<DisplaySafeUrl, Error> {
303 DisplaySafeUrl::parse(&url).map_err(|source| Error::UrlParse { url, source })
304}
305
306#[derive(Debug, Deserialize)]
308struct BinVersionInfo {
309 #[serde(deserialize_with = "deserialize_version")]
310 version: Version,
311 date: jiff::Timestamp,
312 artifacts: Vec<BinArtifact>,
313}
314
315fn deserialize_version<'de, D>(deserializer: D) -> Result<Version, D::Error>
316where
317 D: serde::Deserializer<'de>,
318{
319 let s = String::deserialize(deserializer)?;
320 Version::from_str(&s).map_err(serde::de::Error::custom)
321}
322
323#[derive(Debug, Deserialize)]
325struct BinArtifact {
326 platform: String,
327 url: String,
328 archive_format: String,
329}
330
331#[derive(Debug)]
333pub struct ResolvedVersion {
334 pub version: Version,
336 artifact_urls: Vec<DisplaySafeUrl>,
338 archive_format: ArchiveFormat,
340}
341
342impl ResolvedVersion {
343 pub fn from_version(binary: Binary, version: Version) -> Result<Self, Error> {
346 let platform = Platform::from_env()?;
347 let platform_name = platform.as_cargo_dist_triple();
348 let archive_format = if platform.os.is_windows() {
349 ArchiveFormat::Zip
350 } else {
351 ArchiveFormat::TarGz
352 };
353 let artifact_urls = binary.download_urls(&version, &platform_name, archive_format)?;
354 Ok(Self {
355 version,
356 artifact_urls,
357 archive_format,
358 })
359 }
360}
361
362#[derive(Debug, Error)]
364pub enum Error {
365 #[error("Failed to download from: {url}")]
366 Download {
367 url: DisplaySafeUrl,
368 #[source]
369 source: reqwest_middleware::Error,
370 },
371
372 #[error("Failed to read from: {url}")]
373 Stream {
374 url: DisplaySafeUrl,
375 #[source]
376 source: reqwest::Error,
377 },
378
379 #[error("Failed to parse URL: {url}")]
380 UrlParse {
381 url: String,
382 #[source]
383 source: uv_redacted::DisplaySafeUrlError,
384 },
385
386 #[error("Failed to extract archive")]
387 Extract {
388 #[source]
389 source: ExtractError,
390 },
391
392 #[error("Binary not found in archive at expected location: {expected}")]
393 BinaryNotFound { expected: PathBuf },
394
395 #[error(transparent)]
396 Io(#[from] std::io::Error),
397
398 #[error(transparent)]
399 Cache(#[from] CacheError),
400
401 #[error("Failed to detect platform")]
402 Platform(#[from] uv_platform::Error),
403
404 #[error(
405 "Request failed after {retries} {subject} in {duration:.1}s",
406 subject = if *retries > 1 { "retries" } else { "retry" },
407 duration = duration.as_secs_f32()
408 )]
409 RetriedError {
410 #[source]
411 err: Box<Self>,
412 retries: u32,
413 duration: Duration,
414 },
415
416 #[error("Failed to fetch version manifest from: {url}")]
417 ManifestFetch {
418 url: String,
419 #[source]
420 source: reqwest_middleware::Error,
421 },
422
423 #[error("Failed to parse version manifest")]
424 ManifestParse(#[from] serde_json::Error),
425
426 #[error("Invalid UTF-8 in version manifest")]
427 ManifestUtf8(#[from] std::str::Utf8Error),
428
429 #[error("No version of {binary} found matching `{constraints}` for platform `{platform}`")]
430 NoMatchingVersion {
431 binary: Binary,
432 constraints: uv_pep440::VersionSpecifiers,
433 platform: String,
434 },
435
436 #[error("No version of {binary} found for platform `{platform}`")]
437 NoVersionForPlatform { binary: Binary, platform: String },
438
439 #[error("No artifact found for {binary} {version} on platform {platform}")]
440 NoArtifactForPlatform {
441 binary: Binary,
442 version: String,
443 platform: String,
444 },
445
446 #[error("Unsupported archive format: {0}")]
447 UnsupportedArchiveFormat(String),
448
449 #[error(transparent)]
450 SystemTime(#[from] SystemTimeError),
451}
452
453impl RetriableError for Error {
454 fn retries(&self) -> u32 {
455 if let Self::RetriedError { retries, .. } = self {
456 return *retries;
457 }
458 0
459 }
460
461 fn should_try_next_url(&self) -> bool {
465 match self {
466 Self::Download { .. }
467 | Self::ManifestFetch { .. }
468 | Self::ManifestParse(..)
469 | Self::ManifestUtf8(..) => true,
470 Self::Stream { .. } => true,
471 Self::RetriedError { err, .. } => err.should_try_next_url(),
472 err => {
473 let mut source = err.source();
475 while let Some(err) = source {
476 if let Some(io_err) = err.downcast_ref::<io::Error>() {
477 if io_err
478 .get_ref()
479 .and_then(|e| e.downcast_ref::<Self>() as Option<&Self>)
480 .is_some_and(|e| {
481 matches!(e, Self::Stream { .. } | Self::Download { .. })
482 })
483 {
484 return true;
485 }
486 }
487 source = err.source();
488 }
489 retryable_on_request_failure(err) == Some(Retryable::Transient)
491 }
492 }
493 }
494
495 fn into_retried(self, retries: u32, duration: Duration) -> Self {
496 Self::RetriedError {
497 err: Box::new(self),
498 retries,
499 duration,
500 }
501 }
502}
503
504pub async fn find_matching_version(
514 binary: Binary,
515 constraints: Option<&uv_pep440::VersionSpecifiers>,
516 exclude_newer: Option<jiff::Timestamp>,
517 client: &BaseClient,
518 retry_policy: &ExponentialBackoff,
519) -> Result<ResolvedVersion, Error> {
520 let platform = Platform::from_env()?;
521 let platform_name = platform.as_cargo_dist_triple();
522
523 let manifest_urls = binary.manifest_urls()?;
524
525 fetch_with_url_fallback(
526 &manifest_urls,
527 *retry_policy,
528 &format!("manifest for `{binary}`"),
529 |url| {
530 fetch_and_find_matching_version(
531 binary,
532 constraints,
533 exclude_newer,
534 &platform_name,
535 url,
536 client,
537 )
538 },
539 )
540 .await
541}
542
543async fn fetch_and_find_matching_version(
548 binary: Binary,
549 constraints: Option<&uv_pep440::VersionSpecifiers>,
550 exclude_newer: Option<jiff::Timestamp>,
551 platform_name: &str,
552 manifest_url: DisplaySafeUrl,
553 client: &BaseClient,
554) -> Result<ResolvedVersion, Error> {
555 let response = client
556 .for_host(&manifest_url)
557 .get(Url::from(manifest_url.clone()))
558 .send()
559 .await
560 .map_err(|source| Error::ManifestFetch {
561 url: manifest_url.to_string(),
562 source,
563 })?;
564
565 let response = response
566 .error_for_status()
567 .map_err(|err| Error::ManifestFetch {
568 url: manifest_url.to_string(),
569 source: reqwest_middleware::Error::Reqwest(err),
570 })?;
571
572 let parse_and_check = |line: &[u8]| -> Result<Option<ResolvedVersion>, Error> {
574 let line_str = std::str::from_utf8(line)?;
575 if line_str.trim().is_empty() {
576 return Ok(None);
577 }
578 let version_info: BinVersionInfo = serde_json::from_str(line_str)?;
579 check_version_match(
580 binary,
581 &version_info,
582 constraints,
583 exclude_newer,
584 platform_name,
585 )
586 };
587
588 let mut stream = response.bytes_stream();
590 let mut buffer = Vec::new();
591
592 while let Some(chunk) = stream.next().await {
593 let chunk = chunk.map_err(|err| Error::ManifestFetch {
594 url: manifest_url.to_string(),
595 source: reqwest_middleware::Error::Reqwest(err),
596 })?;
597 buffer.extend_from_slice(&chunk);
598
599 while let Some(newline_pos) = buffer.iter().position(|&b| b == b'\n') {
601 let line = &buffer[..newline_pos];
602 let result = parse_and_check(line)?;
603 buffer.drain(..=newline_pos);
604
605 if let Some(resolved) = result {
606 return Ok(resolved);
607 }
608 }
609 }
610
611 if let Some(resolved) = parse_and_check(&buffer)? {
613 return Ok(resolved);
614 }
615
616 match constraints {
618 Some(constraints) => Err(Error::NoMatchingVersion {
619 binary,
620 constraints: constraints.clone(),
621 platform: platform_name.to_string(),
622 }),
623 None => Err(Error::NoVersionForPlatform {
624 binary,
625 platform: platform_name.to_string(),
626 }),
627 }
628}
629
630fn check_version_match(
635 binary: Binary,
636 version_info: &BinVersionInfo,
637 constraints: Option<&uv_pep440::VersionSpecifiers>,
638 exclude_newer: Option<jiff::Timestamp>,
639 platform_name: &str,
640) -> Result<Option<ResolvedVersion>, Error> {
641 if let Some(cutoff) = exclude_newer
643 && version_info.date > cutoff
644 {
645 return Ok(None);
646 }
647
648 if let Some(constraints) = constraints
650 && !constraints.contains(&version_info.version)
651 {
652 return Ok(None);
653 }
654
655 for artifact in &version_info.artifacts {
658 if artifact.platform != platform_name {
659 continue;
660 }
661
662 let Ok(canonical_url) = DisplaySafeUrl::parse(&artifact.url) else {
663 continue;
664 };
665
666 let archive_format = match artifact.archive_format.as_str() {
667 "tar.gz" => ArchiveFormat::TarGz,
668 "zip" => ArchiveFormat::Zip,
669 _ => continue,
670 };
671
672 return Ok(Some(ResolvedVersion {
673 version: version_info.version.clone(),
674 artifact_urls: binary.mirror_urls(canonical_url)?,
675 archive_format,
676 }));
677 }
678
679 Ok(None)
680}
681
682pub async fn bin_install(
684 binary: Binary,
685 resolved: &ResolvedVersion,
686 client: &BaseClient,
687 retry_policy: &ExponentialBackoff,
688 cache: &Cache,
689 reporter: &dyn Reporter,
690) -> Result<PathBuf, Error> {
691 let platform = Platform::from_env()?;
692 let platform_name = platform.as_cargo_dist_triple();
693
694 bin_install_from_urls(
695 binary,
696 &resolved.version,
697 &resolved.artifact_urls,
698 resolved.archive_format,
699 &platform_name,
700 client,
701 retry_policy,
702 cache,
703 reporter,
704 )
705 .await
706}
707
708async fn bin_install_from_urls(
710 binary: Binary,
711 version: &Version,
712 download_urls: &[DisplaySafeUrl],
713 format: ArchiveFormat,
714 platform_name: &str,
715 client: &BaseClient,
716 retry_policy: &ExponentialBackoff,
717 cache: &Cache,
718 reporter: &dyn Reporter,
719) -> Result<PathBuf, Error> {
720 let cache_entry = CacheEntry::new(
721 cache
722 .bucket(CacheBucket::Binaries)
723 .join(binary.name())
724 .join(version.to_string())
725 .join(platform_name),
726 binary.executable(),
727 );
728
729 let _lock = cache_entry.with_file(".lock").lock().await?;
731 if cache_entry.path().exists() {
732 return Ok(cache_entry.into_path_buf());
733 }
734
735 let cache_dir = cache_entry.dir();
736 fs_err::tokio::create_dir_all(&cache_dir).await?;
737
738 let path = fetch_with_url_fallback(
739 download_urls,
740 *retry_policy,
741 &format!("`{binary}`"),
742 |url| {
743 download_and_unpack(
744 binary,
745 version,
746 client,
747 cache,
748 reporter,
749 platform_name,
750 format,
751 url,
752 &cache_entry,
753 )
754 },
755 )
756 .await?;
757
758 #[cfg(unix)]
760 {
761 use std::fs::Permissions;
762 use std::os::unix::fs::PermissionsExt;
763 let permissions = fs_err::tokio::metadata(&path).await?.permissions();
764 if permissions.mode() & 0o111 != 0o111 {
765 fs_err::tokio::set_permissions(
766 &path,
767 Permissions::from_mode(permissions.mode() | 0o111),
768 )
769 .await?;
770 }
771 }
772
773 Ok(path)
774}
775
776async fn download_and_unpack(
780 binary: Binary,
781 version: &Version,
782 client: &BaseClient,
783 cache: &Cache,
784 reporter: &dyn Reporter,
785 platform_name: &str,
786 format: ArchiveFormat,
787 download_url: DisplaySafeUrl,
788 cache_entry: &CacheEntry,
789) -> Result<PathBuf, Error> {
790 let temp_dir = tempfile::tempdir_in(cache.bucket(CacheBucket::Binaries))?;
792
793 let response = client
794 .for_host(&download_url)
795 .get(Url::from(download_url.clone()))
796 .send()
797 .await
798 .map_err(|err| Error::Download {
799 url: download_url.clone(),
800 source: err,
801 })?;
802
803 let inner_retries = response
804 .extensions()
805 .get::<reqwest_retry::RetryCount>()
806 .map(|retries| retries.value());
807
808 if let Err(status_error) = response.error_for_status_ref() {
809 let err = Error::Download {
810 url: download_url.clone(),
811 source: reqwest_middleware::Error::from(status_error),
812 };
813 if let Some(retries) = inner_retries {
814 return Err(Error::RetriedError {
815 err: Box::new(err),
816 retries,
817 duration: Duration::default(),
819 });
820 }
821 return Err(err);
822 }
823
824 let size = response
826 .headers()
827 .get(reqwest::header::CONTENT_LENGTH)
828 .and_then(|val| val.to_str().ok())
829 .and_then(|val| val.parse::<u64>().ok());
830
831 let reader = response
833 .bytes_stream()
834 .map_err(|err| {
835 std::io::Error::other(Error::Stream {
836 url: download_url.clone(),
837 source: err,
838 })
839 })
840 .into_async_read()
841 .compat();
842
843 let id = reporter.on_download_start(binary.name(), version, size);
844 let mut progress_reader = ProgressReader::new(reader, id, reporter);
845 stream::archive(&mut progress_reader, format.into(), temp_dir.path())
846 .await
847 .map_err(|e| Error::Extract { source: e })?;
848 reporter.on_download_complete(id);
849
850 let extracted_binary = match format {
852 ArchiveFormat::Zip => {
853 temp_dir.path().join(binary.executable())
855 }
856 ArchiveFormat::TarGz => {
857 temp_dir
859 .path()
860 .join(format!("{}-{platform_name}", binary.name()))
861 .join(binary.executable())
862 }
863 };
864
865 if !extracted_binary.exists() {
866 return Err(Error::BinaryNotFound {
867 expected: extracted_binary,
868 });
869 }
870
871 fs_err::tokio::rename(&extracted_binary, cache_entry.path()).await?;
873
874 Ok(cache_entry.path().to_path_buf())
875}
876
877pub trait Reporter: Send + Sync {
879 fn on_download_start(&self, name: &str, version: &Version, size: Option<u64>) -> usize;
881 fn on_download_progress(&self, id: usize, inc: u64);
883 fn on_download_complete(&self, id: usize);
885}
886
887struct ProgressReader<'a, R> {
889 reader: R,
890 index: usize,
891 reporter: &'a dyn Reporter,
892}
893
894impl<'a, R> ProgressReader<'a, R> {
895 fn new(reader: R, index: usize, reporter: &'a dyn Reporter) -> Self {
897 Self {
898 reader,
899 index,
900 reporter,
901 }
902 }
903}
904
905impl<R> AsyncRead for ProgressReader<'_, R>
906where
907 R: AsyncRead + Unpin,
908{
909 fn poll_read(
910 mut self: Pin<&mut Self>,
911 cx: &mut Context<'_>,
912 buf: &mut ReadBuf<'_>,
913 ) -> Poll<std::io::Result<()>> {
914 Pin::new(&mut self.as_mut().reader)
915 .poll_read(cx, buf)
916 .map_ok(|()| {
917 self.reporter
918 .on_download_progress(self.index, buf.filled().len() as u64);
919 })
920 }
921}
922
923#[cfg(test)]
924mod tests {
925 use serde_json::json;
926 use std::io::Write;
927 use uv_client::{BaseClientBuilder, fetch_with_url_fallback, retryable_on_request_failure};
928 use uv_redacted::DisplaySafeUrl;
929 use wiremock::matchers::{method, path};
930 use wiremock::{Mock, MockServer, ResponseTemplate};
931
932 use super::*;
933
934 async fn spawn_manifest_server(response: ResponseTemplate) -> (DisplaySafeUrl, MockServer) {
935 let server = MockServer::start().await;
936 Mock::given(method("GET"))
937 .and(path("/uv.ndjson"))
938 .respond_with(response)
939 .mount(&server)
940 .await;
941
942 (
943 DisplaySafeUrl::parse(&format!("{}/uv.ndjson", server.uri())).unwrap(),
944 server,
945 )
946 }
947
948 fn manifest_response(body: &str) -> ResponseTemplate {
949 ResponseTemplate::new(200).set_body_raw(body.to_owned(), "application/x-ndjson")
950 }
951
952 fn not_found_response() -> ResponseTemplate {
953 ResponseTemplate::new(404)
954 }
955
956 fn uv_manifest_line(version: &str, platform: &str) -> String {
957 let extension = if cfg!(windows) { "zip" } else { "tar.gz" };
958 let url = format!(
959 "https://github.com/astral-sh/uv/releases/download/{version}/uv-{platform}.{extension}"
960 );
961
962 format!(
963 "{}\n",
964 json!({
965 "version": version,
966 "date": "2025-01-01T00:00:00Z",
967 "artifacts": [{
968 "platform": platform,
969 "url": url,
970 "archive_format": extension,
971 }],
972 })
973 )
974 }
975
976 async fn resolve_version_from_manifest_urls(
977 urls: &[DisplaySafeUrl],
978 constraints: Option<&VersionSpecifiers>,
979 ) -> Result<ResolvedVersion, Error> {
980 let platform = Platform::from_env().unwrap();
981 let platform_name = platform.as_cargo_dist_triple();
982 let client_builder = BaseClientBuilder::default().retries(0);
983 let retry_policy = client_builder.retry_policy();
984 let client = client_builder.build().expect("failed to build base client");
985
986 fetch_with_url_fallback(urls, retry_policy, "manifest for `uv`", |url| {
987 fetch_and_find_matching_version(
988 Binary::Uv,
989 constraints,
990 None,
991 &platform_name,
992 url,
993 &client,
994 )
995 })
996 .await
997 }
998
999 #[test]
1000 fn test_uv_download_urls() {
1001 let urls = Binary::Uv
1002 .download_urls(
1003 &Version::new([0, 6, 0]),
1004 "x86_64-unknown-linux-gnu",
1005 ArchiveFormat::TarGz,
1006 )
1007 .expect("uv download URLs should be valid");
1008
1009 let urls = urls
1010 .into_iter()
1011 .map(|url| url.to_string())
1012 .collect::<Vec<_>>();
1013 assert_eq!(
1014 urls,
1015 vec![
1016 "https://github.com/astral-sh/uv/releases/download/0.6.0/uv-x86_64-unknown-linux-gnu.tar.gz"
1017 .to_string(),
1018 ]
1019 );
1020 }
1021
1022 #[test]
1023 fn test_ruff_download_urls_custom_astral_mirror() {
1024 let urls = Binary::Ruff
1025 .download_urls_with_astral_mirror(
1026 &Version::new([0, 15, 1]),
1027 "x86_64-unknown-linux-gnu",
1028 ArchiveFormat::TarGz,
1029 Some("https://nexus.example.com/repository/releases.astral.sh/"),
1030 )
1031 .expect("ruff download URLs should be valid");
1032
1033 let urls = urls
1034 .into_iter()
1035 .map(|url| url.to_string())
1036 .collect::<Vec<_>>();
1037 assert_eq!(
1038 urls,
1039 vec![
1040 "https://nexus.example.com/repository/releases.astral.sh/github/ruff/releases/download/0.15.1/ruff-x86_64-unknown-linux-gnu.tar.gz"
1041 .to_string(),
1042 ]
1043 );
1044 }
1045
1046 #[test]
1047 fn test_ruff_download_urls_empty_astral_mirror_uses_default() {
1048 let default_urls = Binary::Ruff
1049 .download_urls_with_astral_mirror(
1050 &Version::new([0, 15, 1]),
1051 "x86_64-unknown-linux-gnu",
1052 ArchiveFormat::TarGz,
1053 None,
1054 )
1055 .expect("ruff download URLs should be valid");
1056 let empty_urls = Binary::Ruff
1057 .download_urls_with_astral_mirror(
1058 &Version::new([0, 15, 1]),
1059 "x86_64-unknown-linux-gnu",
1060 ArchiveFormat::TarGz,
1061 Some(""),
1062 )
1063 .expect("ruff download URLs should be valid");
1064
1065 assert_eq!(default_urls, empty_urls);
1066 }
1067
1068 #[test]
1069 fn test_ty_download_urls_custom_astral_mirror() {
1070 let urls = Binary::Ty
1071 .download_urls_with_astral_mirror(
1072 &Version::new([0, 0, 1]),
1073 "x86_64-unknown-linux-gnu",
1074 ArchiveFormat::TarGz,
1075 Some("https://nexus.example.com/repository/releases.astral.sh/"),
1076 )
1077 .expect("ty download URLs should be valid");
1078
1079 let urls = urls
1080 .into_iter()
1081 .map(|url| url.to_string())
1082 .collect::<Vec<_>>();
1083 assert_eq!(
1084 urls,
1085 vec![
1086 "https://nexus.example.com/repository/releases.astral.sh/github/ty/releases/download/0.0.1/ty-x86_64-unknown-linux-gnu.tar.gz"
1087 .to_string(),
1088 ]
1089 );
1090 }
1091
1092 #[test]
1093 fn test_ty_download_urls_use_default_astral_mirror_then_github() {
1094 let default_urls = Binary::Ty
1095 .download_urls_with_astral_mirror(
1096 &Version::new([0, 0, 1]),
1097 "x86_64-unknown-linux-gnu",
1098 ArchiveFormat::TarGz,
1099 None,
1100 )
1101 .expect("ty download URLs should be valid");
1102 let empty_urls = Binary::Ty
1103 .download_urls_with_astral_mirror(
1104 &Version::new([0, 0, 1]),
1105 "x86_64-unknown-linux-gnu",
1106 ArchiveFormat::TarGz,
1107 Some(""),
1108 )
1109 .expect("ty download URLs should be valid");
1110
1111 assert_eq!(default_urls, empty_urls);
1112 assert_eq!(
1113 default_urls,
1114 vec![
1115 DisplaySafeUrl::parse(
1116 "https://releases.astral.sh/github/ty/releases/download/0.0.1/ty-x86_64-unknown-linux-gnu.tar.gz",
1117 )
1118 .expect("default Astral mirror ty URL should be valid"),
1119 DisplaySafeUrl::parse(
1120 "https://github.com/astral-sh/ty/releases/download/0.0.1/ty-x86_64-unknown-linux-gnu.tar.gz",
1121 )
1122 .expect("canonical ty URL should be valid"),
1123 ]
1124 );
1125 }
1126
1127 #[test]
1128 fn test_manifest_urls_custom_astral_mirror() {
1129 for (binary, filename) in [
1130 (Binary::Ruff, "ruff.ndjson"),
1131 (Binary::Ty, "ty.ndjson"),
1132 (Binary::Uv, "uv.ndjson"),
1133 ] {
1134 let urls = binary
1135 .manifest_urls_with_astral_mirror(Some(
1136 "https://nexus.example.com/repository/releases.astral.sh/",
1137 ))
1138 .expect("manifest URLs should be valid");
1139
1140 let urls = urls
1141 .into_iter()
1142 .map(|url| url.to_string())
1143 .collect::<Vec<_>>();
1144 assert_eq!(
1145 urls,
1146 vec![format!(
1147 "https://nexus.example.com/repository/releases.astral.sh/github/versions/main/v1/{filename}"
1148 )]
1149 );
1150 }
1151 }
1152
1153 #[test]
1154 fn test_ruff_mirror_urls_custom_astral_mirror() {
1155 let canonical_url = DisplaySafeUrl::parse(
1156 "https://github.com/astral-sh/ruff/releases/download/0.15.1/ruff-x86_64-unknown-linux-gnu.tar.gz",
1157 )
1158 .unwrap();
1159 let urls = Binary::Ruff
1160 .mirror_urls_with_astral_mirror(
1161 canonical_url,
1162 Some("https://nexus.example.com/repository/releases.astral.sh/"),
1163 )
1164 .expect("mirror URLs should be valid");
1165
1166 let urls = urls
1167 .into_iter()
1168 .map(|url| url.to_string())
1169 .collect::<Vec<_>>();
1170 assert_eq!(
1171 urls,
1172 vec![
1173 "https://nexus.example.com/repository/releases.astral.sh/github/ruff/releases/download/0.15.1/ruff-x86_64-unknown-linux-gnu.tar.gz"
1174 .to_string(),
1175 ]
1176 );
1177 }
1178
1179 #[test]
1180 fn test_ty_mirror_urls_custom_astral_mirror() {
1181 let canonical_url = DisplaySafeUrl::parse(
1182 "https://github.com/astral-sh/ty/releases/download/0.0.1/ty-x86_64-unknown-linux-gnu.tar.gz",
1183 )
1184 .expect("canonical ty URL should be valid");
1185 let urls = Binary::Ty
1186 .mirror_urls_with_astral_mirror(
1187 canonical_url,
1188 Some("https://nexus.example.com/repository/releases.astral.sh/"),
1189 )
1190 .expect("mirror URLs should be valid");
1191
1192 let urls = urls
1193 .into_iter()
1194 .map(|url| url.to_string())
1195 .collect::<Vec<_>>();
1196 assert_eq!(
1197 urls,
1198 vec![
1199 "https://nexus.example.com/repository/releases.astral.sh/github/ty/releases/download/0.0.1/ty-x86_64-unknown-linux-gnu.tar.gz"
1200 .to_string(),
1201 ]
1202 );
1203 }
1204
1205 #[test]
1206 fn test_ty_mirror_urls_use_default_astral_mirror_then_github() {
1207 let canonical_url = DisplaySafeUrl::parse(
1208 "https://github.com/astral-sh/ty/releases/download/0.0.1/ty-x86_64-unknown-linux-gnu.tar.gz",
1209 )
1210 .expect("canonical ty URL should be valid");
1211 let default_urls = Binary::Ty
1212 .mirror_urls_with_astral_mirror(canonical_url.clone(), None)
1213 .expect("mirror URLs should be valid");
1214 let empty_urls = Binary::Ty
1215 .mirror_urls_with_astral_mirror(canonical_url.clone(), Some(""))
1216 .expect("mirror URLs should be valid");
1217
1218 assert_eq!(default_urls, empty_urls);
1219 assert_eq!(
1220 default_urls,
1221 vec![
1222 DisplaySafeUrl::parse(
1223 "https://releases.astral.sh/github/ty/releases/download/0.0.1/ty-x86_64-unknown-linux-gnu.tar.gz",
1224 )
1225 .expect("default Astral mirror ty URL should be valid"),
1226 canonical_url,
1227 ]
1228 );
1229 }
1230
1231 #[test]
1232 fn test_manifest_urls_empty_astral_mirror_uses_default() {
1233 for binary in [Binary::Ruff, Binary::Ty, Binary::Uv] {
1234 let default_urls = binary
1235 .manifest_urls_with_astral_mirror(None)
1236 .expect("manifest URLs should be valid");
1237 let empty_urls = binary
1238 .manifest_urls_with_astral_mirror(Some(""))
1239 .expect("manifest URLs should be valid");
1240 assert_eq!(default_urls, empty_urls);
1241 }
1242 }
1243
1244 #[test]
1245 fn test_ruff_mirror_urls_empty_astral_mirror_uses_default() {
1246 let canonical_url = DisplaySafeUrl::parse(
1247 "https://github.com/astral-sh/ruff/releases/download/0.15.1/ruff-x86_64-unknown-linux-gnu.tar.gz",
1248 )
1249 .unwrap();
1250 let default_urls = Binary::Ruff
1251 .mirror_urls_with_astral_mirror(canonical_url.clone(), None)
1252 .expect("mirror URLs should be valid");
1253 let empty_urls = Binary::Ruff
1254 .mirror_urls_with_astral_mirror(canonical_url, Some(""))
1255 .expect("mirror URLs should be valid");
1256
1257 assert_eq!(default_urls, empty_urls);
1258 }
1259
1260 #[tokio::test]
1261 async fn test_manifest_falls_back_on_404() {
1262 let platform = Platform::from_env().unwrap();
1263 let platform_name = platform.as_cargo_dist_triple();
1264 let (mirror_url, mirror_server) = spawn_manifest_server(not_found_response()).await;
1265 let (canonical_url, canonical_server) = spawn_manifest_server(manifest_response(
1266 &uv_manifest_line("1.2.3", &platform_name),
1267 ))
1268 .await;
1269
1270 let resolved = resolve_version_from_manifest_urls(&[mirror_url, canonical_url], None)
1271 .await
1272 .expect("404 from mirror should fall back to canonical manifest");
1273
1274 assert_eq!(resolved.version, Version::new([1, 2, 3]));
1275 assert_eq!(mirror_server.received_requests().await.unwrap().len(), 1);
1276 assert_eq!(canonical_server.received_requests().await.unwrap().len(), 1);
1277 }
1278
1279 #[tokio::test]
1280 async fn test_manifest_falls_back_on_parse_error() {
1281 let platform = Platform::from_env().unwrap();
1282 let platform_name = platform.as_cargo_dist_triple();
1283 let (mirror_url, mirror_server) =
1284 spawn_manifest_server(manifest_response("{not json}\n")).await;
1285 let (canonical_url, canonical_server) = spawn_manifest_server(manifest_response(
1286 &uv_manifest_line("1.2.3", &platform_name),
1287 ))
1288 .await;
1289
1290 let resolved = resolve_version_from_manifest_urls(&[mirror_url, canonical_url], None)
1291 .await
1292 .expect("parse failure from mirror should fall back to canonical manifest");
1293
1294 assert_eq!(resolved.version, Version::new([1, 2, 3]));
1295 assert_eq!(mirror_server.received_requests().await.unwrap().len(), 1);
1296 assert_eq!(canonical_server.received_requests().await.unwrap().len(), 1);
1297 }
1298
1299 #[tokio::test]
1300 async fn test_manifest_no_matching_version_does_not_fallback() {
1301 let platform = Platform::from_env().unwrap();
1302 let platform_name = platform.as_cargo_dist_triple();
1303 let (mirror_url, mirror_server) = spawn_manifest_server(manifest_response(
1304 &uv_manifest_line("1.2.3", &platform_name),
1305 ))
1306 .await;
1307 let (canonical_url, canonical_server) = spawn_manifest_server(manifest_response(
1308 &uv_manifest_line("9.9.9", &platform_name),
1309 ))
1310 .await;
1311 let constraints =
1312 VersionSpecifiers::from(VersionSpecifier::equals_version(Version::new([9, 9, 9])));
1313
1314 let err =
1315 resolve_version_from_manifest_urls(&[mirror_url, canonical_url], Some(&constraints))
1316 .await
1317 .expect_err("no matching version should not fall back to canonical manifest");
1318
1319 assert!(matches!(err, Error::NoMatchingVersion { .. }));
1320 assert_eq!(mirror_server.received_requests().await.unwrap().len(), 1);
1321 assert_eq!(canonical_server.received_requests().await.unwrap().len(), 0);
1322 }
1323
1324 #[tokio::test]
1330 async fn test_non_retryable_stream_error_triggers_url_fallback() {
1331 use futures::TryStreamExt;
1332
1333 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1334 let addr = listener.local_addr().unwrap();
1335
1336 std::thread::spawn(move || {
1337 let (mut stream, _) = listener.accept().unwrap();
1338 let mut buf = [0u8; 4096];
1339 let _ = std::io::Read::read(&mut stream, &mut buf);
1340 stream
1341 .write_all(
1342 b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nZZZ\r\nhello\r\n0\r\n\r\n",
1343 )
1344 .unwrap();
1345 });
1346
1347 let url = DisplaySafeUrl::parse(&format!("http://{addr}/ruff.tar.gz")).unwrap();
1348 let client = BaseClientBuilder::default()
1349 .build()
1350 .expect("failed to build base client");
1351 let response = client
1352 .for_host(&url)
1353 .get(Url::from(url.clone()))
1354 .send()
1355 .await
1356 .unwrap();
1357
1358 let reqwest_err = response.bytes_stream().try_next().await.unwrap_err();
1359 assert!(reqwest_err.is_body() || reqwest_err.is_decode());
1360
1361 let err = Error::Extract {
1362 source: ExtractError::Io(io::Error::other(Error::Stream {
1363 url,
1364 source: reqwest_err,
1365 })),
1366 };
1367
1368 assert!(retryable_on_request_failure(&err).is_none());
1369 assert!(
1370 err.should_try_next_url(),
1371 "non-retryable streaming error should still trigger URL fallback, got: {err}"
1372 );
1373 }
1374}