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::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/// Binary tools that can be installed.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub enum Binary {
36    Ruff,
37    Uv,
38}
39
40impl Binary {
41    /// Get the default version constraints for this binary.
42    ///
43    /// Returns a version range constraint (e.g., `>=0.15,<0.16`) rather than a pinned version,
44    /// allowing patch version updates without requiring a uv release.
45    pub fn default_constraints(&self) -> VersionSpecifiers {
46        match self {
47            // TODO(zanieb): Figure out a nice way to automate updating this
48            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    /// The name of the binary.
59    ///
60    /// See [`Binary::executable`] for the platform-specific executable name.
61    pub fn name(&self) -> &'static str {
62        match self {
63            Self::Ruff => "ruff",
64            Self::Uv => "uv",
65        }
66    }
67
68    /// Get the ordered list of download URLs for a specific version and platform.
69    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    /// Return the ordered list of manifest URLs to try for this binary.
107    fn manifest_urls(self) -> Vec<DisplaySafeUrl> {
108        let name = self.name();
109        match self {
110            // These are static strings so parsing cannot fail.
111            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    /// Given a canonical artifact URL (e.g., from the versions manifest), return the ordered list
125    /// of URLs to try for this binary.
126    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    /// Get the executable name
142    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/// Archive formats for binary downloads.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum ArchiveFormat {
156    Zip,
157    TarGz,
158}
159
160impl ArchiveFormat {
161    /// Get the file extension for this archive format.
162    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/// Specifies which version of a binary to use.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub enum BinVersion {
182    /// Use the binary's default pinned version.
183    Default,
184    /// Fetch the latest version from the manifest.
185    Latest,
186    /// Use a specific pinned version.
187    Pinned(Version),
188    /// Find the best version matching the given constraints.
189    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        // Try parsing as an exact version first
200        if let Ok(version) = Version::from_str(s) {
201            return Ok(Self::Pinned(version));
202        }
203        // Otherwise parse as version specifiers
204        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
220/// The canonical GitHub URL prefix for Ruff releases.
221const RUFF_GITHUB_URL_PREFIX: &str = "https://github.com/astral-sh/ruff/releases/download/";
222
223/// The canonical GitHub URL prefix for uv releases.
224const UV_GITHUB_URL_PREFIX: &str = "https://github.com/astral-sh/uv/releases/download/";
225
226/// The default Astral mirror for Ruff releases.
227///
228/// This mirror is tried first for Ruff downloads. If it fails, uv falls back to the canonical
229/// GitHub URL.
230const RUFF_DEFAULT_MIRROR: &str = "https://releases.astral.sh/github/ruff/releases/download/";
231
232/// The canonical base URL for the versions manifest.
233const VERSIONS_MANIFEST_URL: &str = "https://raw.githubusercontent.com/astral-sh/versions/main/v1";
234
235/// The default Astral mirror for the versions manifest.
236const VERSIONS_MANIFEST_MIRROR: &str = "https://releases.astral.sh/github/versions/main/v1";
237
238/// Binary version information from the versions manifest.
239#[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/// Binary artifact information.
256#[derive(Debug, Deserialize)]
257struct BinArtifact {
258    platform: String,
259    url: String,
260    archive_format: String,
261}
262
263/// A resolved version with its artifact information.
264#[derive(Debug)]
265pub struct ResolvedVersion {
266    /// The version number.
267    pub version: Version,
268    /// The ordered list of download URLs to try for this version and current platform.
269    pub artifact_urls: Vec<DisplaySafeUrl>,
270    /// The archive format.
271    pub archive_format: ArchiveFormat,
272}
273
274impl ResolvedVersion {
275    /// Construct a [`ResolvedVersion`] from a [`Binary`] and a [`Version`] by inferring the
276    /// download URLs and archive format from the current platform.
277    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/// Errors that can occur during binary download and installation.
295#[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    /// Returns `true` if trying an alternative URL makes sense after this error.
394    ///
395    /// Download and streaming failures qualify, as do malformed manifest responses.
396    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                // Walk the error chain to see if there's a nested download or streaming error.
406                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                // Make sure all retriable errors also trigger a fallback to the next URL.
422                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
436/// Find a version of a binary that matches the given constraints.
437///
438/// This streams the NDJSON manifest line-by-line, returning the first version
439/// that matches the constraints (versions are sorted newest-first).
440///
441/// If no constraints are provided, returns the latest version.
442///
443/// If `exclude_newer` is provided, versions with a release date newer than the
444/// given timestamp will be skipped.
445pub 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
473/// Fetch the manifest from a single URL and find a matching version.
474///
475/// Separated from [`find_matching_version`] so that [`fetch_with_url_fallback`] can call it
476/// independently for each URL in the fallback list.
477async 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    // Parse a single JSON line and check if it matches the constraints and platform.
503    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    // Stream the response line by line
519    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        // Process complete lines
530        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    // Process any remaining data in buffer (in case there's no trailing newline)
542    if let Some(resolved) = parse_and_check(&buffer)? {
543        return Ok(resolved);
544    }
545
546    // No matching version found
547    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
560/// Check if a version matches the constraints and find the artifact for the platform.
561///
562/// Returns `Some(resolved)` if the version matches and an artifact is found,
563/// `None` if the version doesn't match or no artifact is available for the platform.
564fn 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    // Skip versions newer than the exclude_newer cutoff
572    if let Some(cutoff) = exclude_newer
573        && version_info.date > cutoff
574    {
575        return None;
576    }
577
578    // Skip versions that don't match the constraints
579    if let Some(constraints) = constraints
580        && !constraints.contains(&version_info.version)
581    {
582        return None;
583    }
584
585    // Find an artifact matching the platform, trusting whichever archive format the
586    // manifest reports.
587    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
612/// Install the given binary from a [`ResolvedVersion`].
613pub 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
638/// Install a binary from an ordered list of URLs, trying each in sequence.
639async 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    // Lock the directory to prevent racing installs
660    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    // Add executable bit
689    #[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
706/// Download and unpack a binary from a single URL.
707///
708/// Use [`bin_install_from_urls`] (via [`fetch_with_url_fallback`]) to get URL-fallback and retry.
709async 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    // Create a temporary directory for extraction
721    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                // This value is overwritten in `download_and_unpack_with_retry`.
748                duration: Duration::default(),
749            });
750        }
751        return Err(err);
752    }
753
754    // Get the download size from headers if available
755    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    // Stream download directly to extraction
762    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    // Find the binary in the extracted files
786    let extracted_binary = match format {
787        ArchiveFormat::Zip => {
788            // Windows ZIP archives contain the binary directly in the root
789            temp_dir.path().join(binary.executable())
790        }
791        ArchiveFormat::TarGz => {
792            // tar.gz archives contain the binary in a subdirectory
793            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    // Move the binary to its final location before the temp directory is dropped
807    fs_err::tokio::rename(&extracted_binary, cache_entry.path()).await?;
808
809    Ok(cache_entry.path().to_path_buf())
810}
811
812/// Progress reporter for binary downloads.
813pub trait Reporter: Send + Sync {
814    /// Called when a download starts.
815    fn on_download_start(&self, name: &str, version: &Version, size: Option<u64>) -> usize;
816    /// Called when download progress is made.
817    fn on_download_progress(&self, id: usize, inc: u64);
818    /// Called when a download completes.
819    fn on_download_complete(&self, id: usize);
820}
821
822/// An asynchronous reader that reports progress as bytes are read.
823struct ProgressReader<'a, R> {
824    reader: R,
825    index: usize,
826    reporter: &'a dyn Reporter,
827}
828
829impl<'a, R> ProgressReader<'a, R> {
830    /// Create a new [`ProgressReader`] that wraps another reader.
831    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    /// Verify that `should_try_next_url` returns `true` even for streaming errors
1022    /// that `retryable_on_request_failure` does not recognise as transient.
1023    ///
1024    /// This exercises a realistic body-streaming protocol failure: the server
1025    /// advertises chunked transfer encoding but sends an invalid chunk size.
1026    #[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}