Skip to main content

uv_bin_install/
lib.rs

1//! Binary download and installation utilities for uv.
2//!
3//! These utilities are specifically for consuming distributions that are _not_ Python packages,
4//! e.g., `ruff` (which does have a Python package, but also has standalone binaries on GitHub).
5
6use 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/// Binary tools that can be installed.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum Binary {
38    Ruff,
39    Ty,
40    Uv,
41}
42
43impl Binary {
44    /// Get the default version constraints for this binary.
45    ///
46    /// Returns a version range constraint (e.g., `>=0.15,<0.16`) rather than a pinned version,
47    /// allowing patch version updates without requiring a uv release.
48    pub fn default_constraints(&self) -> VersionSpecifiers {
49        match self {
50            // TODO(zanieb): Figure out a nice way to automate updating this
51            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    /// The name of the binary.
68    ///
69    /// See [`Binary::executable`] for the platform-specific executable name.
70    fn name(self) -> &'static str {
71        match self {
72            Self::Ruff => "ruff",
73            Self::Ty => "ty",
74            Self::Uv => "uv",
75        }
76    }
77
78    /// Get the ordered list of download URLs for a specific version and platform.
79    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                // When using the default mirror, also fall back to GitHub.
109                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                // When using the default mirror, also fall back to GitHub.
121                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    /// Return the ordered list of manifest URLs to try for this binary.
138    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        // When using the default mirror, also fall back to the canonical raw GitHub URL.
153        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    /// Given a canonical artifact URL (e.g., from the versions manifest), return the ordered list
161    /// of URLs to try for this binary.
162    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    /// Get the executable name
203    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/// Archive formats for binary downloads.
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216enum ArchiveFormat {
217    Zip,
218    TarGz,
219}
220
221impl ArchiveFormat {
222    /// Get the file extension for this archive format.
223    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/// Specifies which version of a binary to use.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum BinVersion {
243    /// Use the binary's default pinned version.
244    Default,
245    /// Fetch the latest version from the manifest.
246    Latest,
247    /// Use a specific pinned version.
248    Pinned(Version),
249    /// Find the best version matching the given constraints.
250    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        // Try parsing as an exact version first
261        if let Ok(version) = Version::from_str(s) {
262            return Ok(Self::Pinned(version));
263        }
264        // Otherwise parse as version specifiers
265        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
281/// The canonical GitHub URL prefix for Ruff releases.
282const RUFF_GITHUB_URL_PREFIX: &str = "https://github.com/astral-sh/ruff/releases/download/";
283
284/// The canonical GitHub URL prefix for ty releases.
285const TY_GITHUB_URL_PREFIX: &str = "https://github.com/astral-sh/ty/releases/download/";
286
287/// The canonical GitHub URL prefix for uv releases.
288const UV_GITHUB_URL_PREFIX: &str = "https://github.com/astral-sh/uv/releases/download/";
289
290/// The suffix appended to the Astral mirror base for Ruff releases.
291const RUFF_MIRROR_SUFFIX: &str = "/github/ruff/releases/download/";
292
293/// The suffix appended to the Astral mirror base for ty releases.
294const TY_MIRROR_SUFFIX: &str = "/github/ty/releases/download/";
295
296/// The suffix appended to the Astral mirror base for the versions manifest.
297const VERSIONS_MANIFEST_MIRROR_SUFFIX: &str = "/github/versions/main/v1";
298
299/// The canonical base URL for the versions manifest.
300const 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/// Binary version information from the versions manifest.
307#[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/// Binary artifact information.
324#[derive(Debug, Deserialize)]
325struct BinArtifact {
326    platform: String,
327    url: String,
328    archive_format: String,
329}
330
331/// A resolved version with its artifact information.
332#[derive(Debug)]
333pub struct ResolvedVersion {
334    /// The version number.
335    pub version: Version,
336    /// The ordered list of download URLs to try for this version and current platform.
337    artifact_urls: Vec<DisplaySafeUrl>,
338    /// The archive format.
339    archive_format: ArchiveFormat,
340}
341
342impl ResolvedVersion {
343    /// Construct a [`ResolvedVersion`] from a [`Binary`] and a [`Version`] by inferring the
344    /// download URLs and archive format from the current platform.
345    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/// Errors that can occur during binary download and installation.
363#[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    /// Returns `true` if trying an alternative URL makes sense after this error.
462    ///
463    /// Download and streaming failures qualify, as do malformed manifest responses.
464    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                // Walk the error chain to see if there's a nested download or streaming error.
474                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                // Make sure all retriable errors also trigger a fallback to the next URL.
490                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
504/// Find a version of a binary that matches the given constraints.
505///
506/// This streams the NDJSON manifest line-by-line, returning the first version
507/// that matches the constraints (versions are sorted newest-first).
508///
509/// If no constraints are provided, returns the latest version.
510///
511/// If `exclude_newer` is provided, versions with a release date newer than the
512/// given timestamp will be skipped.
513pub 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
543/// Fetch the manifest from a single URL and find a matching version.
544///
545/// Separated from [`find_matching_version`] so that [`fetch_with_url_fallback`] can call it
546/// independently for each URL in the fallback list.
547async 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    // Parse a single JSON line and check if it matches the constraints and platform.
573    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    // Stream the response line by line
589    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        // Process complete lines
600        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    // Process any remaining data in buffer (in case there's no trailing newline)
612    if let Some(resolved) = parse_and_check(&buffer)? {
613        return Ok(resolved);
614    }
615
616    // No matching version found
617    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
630/// Check if a version matches the constraints and find the artifact for the platform.
631///
632/// Returns `Ok(Some(resolved))` if the version matches and an artifact is found,
633/// `Ok(None)` if the version doesn't match or no artifact is available for the platform.
634fn 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    // Skip versions newer than the exclude_newer cutoff
642    if let Some(cutoff) = exclude_newer
643        && version_info.date > cutoff
644    {
645        return Ok(None);
646    }
647
648    // Skip versions that don't match the constraints
649    if let Some(constraints) = constraints
650        && !constraints.contains(&version_info.version)
651    {
652        return Ok(None);
653    }
654
655    // Find an artifact matching the platform, trusting whichever archive format the
656    // manifest reports.
657    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
682/// Install the given binary from a [`ResolvedVersion`].
683pub 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
708/// Install a binary from an ordered list of URLs, trying each in sequence.
709async 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    // Lock the directory to prevent racing installs
730    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    // Add executable bit
759    #[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
776/// Download and unpack a binary from a single URL.
777///
778/// Use [`bin_install_from_urls`] (via [`fetch_with_url_fallback`]) to get URL-fallback and retry.
779async 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    // Create a temporary directory for extraction
791    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                // This value is overwritten in `download_and_unpack_with_retry`.
818                duration: Duration::default(),
819            });
820        }
821        return Err(err);
822    }
823
824    // Get the download size from headers if available
825    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    // Stream download directly to extraction
832    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    // Find the binary in the extracted files
851    let extracted_binary = match format {
852        ArchiveFormat::Zip => {
853            // Windows ZIP archives contain the binary directly in the root
854            temp_dir.path().join(binary.executable())
855        }
856        ArchiveFormat::TarGz => {
857            // tar.gz archives contain the binary in a subdirectory
858            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    // Move the binary to its final location before the temp directory is dropped
872    fs_err::tokio::rename(&extracted_binary, cache_entry.path()).await?;
873
874    Ok(cache_entry.path().to_path_buf())
875}
876
877/// Progress reporter for binary downloads.
878pub trait Reporter: Send + Sync {
879    /// Called when a download starts.
880    fn on_download_start(&self, name: &str, version: &Version, size: Option<u64>) -> usize;
881    /// Called when download progress is made.
882    fn on_download_progress(&self, id: usize, inc: u64);
883    /// Called when a download completes.
884    fn on_download_complete(&self, id: usize);
885}
886
887/// An asynchronous reader that reports progress as bytes are read.
888struct ProgressReader<'a, R> {
889    reader: R,
890    index: usize,
891    reporter: &'a dyn Reporter,
892}
893
894impl<'a, R> ProgressReader<'a, R> {
895    /// Create a new [`ProgressReader`] that wraps another reader.
896    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    /// Verify that `should_try_next_url` returns `true` even for streaming errors
1325    /// that `retryable_on_request_failure` does not recognise as transient.
1326    ///
1327    /// This exercises a realistic body-streaming protocol failure: the server
1328    /// advertises chunked transfer encoding but sends an invalid chunk size.
1329    #[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}