Skip to main content

uv_distribution/
distribution_database.rs

1use std::cmp::Reverse;
2use std::future::Future;
3use std::io;
4use std::path::Path;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::task::{Context, Poll};
8
9use futures::{FutureExt, TryStreamExt};
10use http_content_range::{ContentRange, ContentRangeBytes, ContentRangeUnbound};
11use rayon::in_place_scope;
12use rayon::prelude::*;
13use rustc_hash::FxHashMap;
14use tokio::io::{AsyncRead, AsyncSeekExt, AsyncWriteExt, ReadBuf};
15use tokio::sync::Semaphore;
16use tokio_util::compat::FuturesAsyncReadCompatExt;
17use tracing::{Instrument, debug, info_span, instrument, warn};
18use url::Url;
19
20use uv_cache::{ArchiveFileId, ArchiveId, Cache, CacheBucket, CacheEntry, WheelCache};
21use uv_cache_info::{CacheInfo, Timestamp};
22use uv_client::{
23    CacheControl, CachedClientError, Connectivity, DataWithCachePolicy, RegistryClient,
24    RequestBuilder, RetryState,
25};
26use uv_configuration::initialize_rayon_once;
27use uv_distribution_filename::WheelFilename;
28use uv_distribution_types::{
29    ArchiveHashPolicy, BuildInfo, BuildableSource, BuiltDist, Dist, DistRef, HashCollection,
30    HashValidation, Hashed, IndexUrl, InstalledDist, MetadataHashPolicy, Name, SourceDist,
31    SourceUrl, parse_url_hashes,
32};
33use uv_extract::dirhash::{DirectoryDigest, HashedFile};
34use uv_extract::hash::Hasher;
35use uv_fs::{LockedFile, write_atomic};
36use uv_git::{GIT_LFS, GitError};
37use uv_platform_tags::Tags;
38use uv_preview::PreviewFeature;
39use uv_pypi_types::{HashDigest, HashDigests, PyProjectToml};
40use uv_python::PythonVariant;
41use uv_redacted::DisplaySafeUrl;
42use uv_types::{BuildContext, BuildStack};
43
44use crate::archive::Archive;
45use crate::error::PythonVersion;
46use crate::extracted_wheel::{ExtractedWheel, HashedWheel, WheelExtractor};
47use crate::hash::http_hash_algorithms;
48use crate::metadata::{ArchiveMetadata, Metadata};
49use crate::source::SourceDistributionBuilder;
50use crate::{Error, LocalWheel, Reporter, RequiresDist};
51
52/// A cached high-level interface to convert distributions (a requirement resolved to a location)
53/// to a wheel or wheel metadata.
54///
55/// For wheel metadata, this happens by either fetching the metadata from the remote wheel or by
56/// building the source distribution. For wheel files, either the wheel is downloaded or a source
57/// distribution is downloaded, built and the new wheel gets returned.
58///
59/// All kinds of wheel sources (index, URL, path) and source distribution source (index, URL, path,
60/// Git) are supported.
61///
62/// This struct also has the task of acquiring locks around source dist builds in general and git
63/// operation especially, as well as respecting concurrency limits.
64pub struct DistributionDatabase<'a, Context: BuildContext> {
65    build_context: &'a Context,
66    builder: SourceDistributionBuilder<'a, Context>,
67    client: ManagedClient<'a>,
68    reporter: Option<Arc<dyn Reporter>>,
69    content_addressed_cache: bool,
70}
71
72impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
73    pub fn new(
74        client: &'a RegistryClient,
75        build_context: &'a Context,
76        downloads_semaphore: Arc<Semaphore>,
77    ) -> Self {
78        // When ZIP validation is disabled, the extracted tree can contain files that aren't
79        // represented in the central directory and therefore aren't included in its digest.
80        // Avoid using an incomplete digest as a content-addressed archive ID.
81        let content_addressed_cache = uv_preview::is_enabled(PreviewFeature::ContentAddressedCache)
82            && !uv_extract::insecure_no_validate();
83        Self {
84            build_context,
85            builder: SourceDistributionBuilder::new(build_context),
86            client: ManagedClient::new(client, downloads_semaphore),
87            reporter: None,
88            content_addressed_cache,
89        }
90    }
91
92    /// Set the build stack to use for the [`DistributionDatabase`].
93    #[must_use]
94    pub fn with_build_stack(self, build_stack: &'a BuildStack) -> Self {
95        Self {
96            builder: self.builder.with_build_stack(build_stack),
97            ..self
98        }
99    }
100
101    /// Set the [`Reporter`] to use for the [`DistributionDatabase`].
102    #[must_use]
103    pub fn with_reporter(self, reporter: Arc<dyn Reporter>) -> Self {
104        Self {
105            builder: self.builder.with_reporter(reporter.clone()),
106            reporter: Some(reporter),
107            ..self
108        }
109    }
110
111    /// Handle a specific `reqwest` error, and convert it to [`io::Error`].
112    fn handle_response_errors(&self, err: reqwest::Error) -> io::Error {
113        if err.is_timeout() {
114            // Assumption: The connect timeout with the 10s default is not the culprit.
115            io::Error::new(
116                io::ErrorKind::TimedOut,
117                format!(
118                    "Failed to download distribution due to network timeout. Try increasing UV_HTTP_TIMEOUT (current value: {}s).",
119                    self.client.unmanaged.read_timeout().as_secs()
120                ),
121            )
122        } else {
123            io::Error::other(err)
124        }
125    }
126
127    /// Acquire an advisory lock for a wheel cache entry.
128    ///
129    /// A remote wheel's content hash is not always available until after the download, so
130    /// concurrent cache fills coordinate on the wheel cache entry instead. The entry is already
131    /// scoped to the distribution's source and wheel filename.
132    ///
133    /// Callers hold the returned lock across cache lookup, download or extraction, and publication.
134    /// A process that waited for another cache fill therefore rechecks and reuses the completed entry.
135    async fn lock_wheel(
136        wheel_entry: &CacheEntry,
137        filename: &WheelFilename,
138    ) -> Result<LockedFile, Error> {
139        // For backwards compatibility, we use the full wheel stem on Windows. Local wheel
140        // extraction and older uv versions use the same key, so changing it would prevent them
141        // from coordinating through a shared cache.
142        #[cfg(windows)]
143        let lock_key = filename.stem();
144        // On other platforms, we use the bounded cache key to avoid filesystem filename limits.
145        #[cfg(not(windows))]
146        let lock_key = filename.cache_key();
147
148        let lock_entry = wheel_entry.with_file(format!("{lock_key}.lock"));
149        lock_entry.lock().await.map_err(Error::CacheLock)
150    }
151
152    /// Either fetch the wheel or fetch and build the source distribution
153    ///
154    /// Returns a wheel that's compliant with the given platform tags.
155    ///
156    /// While hashes will be generated in some cases, hash-checking is only enforced for source
157    /// distributions, and should be enforced by the caller for wheels.
158    #[instrument(skip_all, fields(%dist))]
159    pub async fn get_or_build_wheel(
160        &self,
161        dist: &Dist,
162        tags: &Tags,
163        hashes: ArchiveHashPolicy<'_>,
164    ) -> Result<LocalWheel, Error> {
165        match dist {
166            Dist::Built(built) => self.get_wheel(built, hashes).await,
167            Dist::Source(source) => self.build_wheel(source, tags, hashes).await,
168        }
169    }
170
171    /// Either fetch the only wheel metadata (directly from the index or with range requests) or
172    /// fetch and build the source distribution.
173    ///
174    /// While hashes will be generated in some cases, hash-checking is only enforced for source
175    /// distributions, and should be enforced by the caller for wheels.
176    #[instrument(skip_all, fields(%dist))]
177    pub async fn get_installed_metadata(
178        &self,
179        dist: &InstalledDist,
180    ) -> Result<ArchiveMetadata, Error> {
181        // If the metadata was provided by the user directly, prefer it.
182        if let Some(metadata) = self
183            .build_context
184            .dependency_metadata()
185            .get(dist.name(), Some(dist.version()))
186        {
187            return Ok(ArchiveMetadata::from_metadata23(metadata));
188        }
189
190        let metadata = dist
191            .read_metadata()
192            .map_err(|err| Error::ReadInstalled(Box::new(dist.clone()), err))?;
193
194        Ok(ArchiveMetadata::from_metadata23(metadata.clone()))
195    }
196
197    /// Retrieve distribution metadata and any hashes requested for resolution.
198    ///
199    /// Source archive hashes are validated before executing build backends; wheel archive hash
200    /// validation is deferred to installation.
201    #[instrument(skip_all, fields(%dist))]
202    pub async fn get_or_build_wheel_metadata(
203        &self,
204        dist: &Dist,
205        hashes: MetadataHashPolicy<'_>,
206    ) -> Result<ArchiveMetadata, Error> {
207        match dist {
208            Dist::Built(built) => self.get_wheel_metadata(built, hashes).await,
209            Dist::Source(source) => {
210                self.build_wheel_metadata(&BuildableSource::Dist(source), hashes)
211                    .await
212            }
213        }
214    }
215
216    /// Fetch a wheel from the cache or download it from the index.
217    ///
218    /// While hashes will be generated in all cases, hash-checking is _not_ enforced and should
219    /// instead be enforced by the caller.
220    async fn get_wheel(
221        &self,
222        dist: &BuiltDist,
223        hashes: ArchiveHashPolicy<'_>,
224    ) -> Result<LocalWheel, Error> {
225        match dist {
226            BuiltDist::Registry(wheels) => {
227                let wheel = wheels.best_wheel();
228                let url = wheel.file.url.to_url()?;
229                let size = wheel.file.size;
230
231                // Create a cache entry for the wheel.
232                let wheel_entry = self.build_context.cache().entry(
233                    CacheBucket::Wheels,
234                    WheelCache::Index(&wheel.index).wheel_dir(wheel.name().as_ref()),
235                    wheel.filename.cache_key(),
236                );
237
238                // If the URL is a file URL, load the wheel directly.
239                if url.scheme() == "file" {
240                    let path = url
241                        .to_file_path()
242                        .map_err(|()| Error::NonFileUrl(url.clone()))?;
243                    return self
244                        .load_wheel(&path, &wheel.filename, wheel_entry, dist, hashes)
245                        .await;
246                }
247
248                // Download and unzip.
249                match self
250                    .stream_wheel(
251                        url.clone(),
252                        dist.index(),
253                        &wheel.filename,
254                        size,
255                        &wheel_entry,
256                        dist,
257                        hashes,
258                    )
259                    .await
260                {
261                    Ok(archive) => Ok(LocalWheel {
262                        dist: Dist::Built(dist.clone()),
263                        archive: self
264                            .build_context
265                            .cache()
266                            .archive(&archive.id)
267                            .into_boxed_path(),
268                        hashes: archive.hashes,
269                        filename: wheel.filename.clone(),
270                        cache: CacheInfo::default(),
271                        build: None,
272                    }),
273                    Err(Error::Extract(name, err)) => {
274                        if err.is_http_streaming_unsupported() {
275                            warn!(
276                                "Streaming unsupported for {dist}; downloading wheel to disk ({err})"
277                            );
278                        } else if err.is_http_streaming_failed() {
279                            warn!("Streaming failed for {dist}; downloading wheel to disk ({err})");
280                        } else {
281                            return Err(Error::Extract(name, err));
282                        }
283
284                        // If the request failed because streaming was unsupported or failed,
285                        // download the wheel directly.
286                        let archive = self
287                            .download_wheel(
288                                url,
289                                dist.index(),
290                                &wheel.filename,
291                                size,
292                                &wheel_entry,
293                                dist,
294                                hashes,
295                            )
296                            .await?;
297
298                        Ok(LocalWheel {
299                            dist: Dist::Built(dist.clone()),
300                            archive: self
301                                .build_context
302                                .cache()
303                                .archive(&archive.id)
304                                .into_boxed_path(),
305                            hashes: archive.hashes,
306                            filename: wheel.filename.clone(),
307                            cache: CacheInfo::default(),
308                            build: None,
309                        })
310                    }
311                    Err(err) => Err(err),
312                }
313            }
314
315            BuiltDist::DirectUrl(wheel) => {
316                // Create a cache entry for the wheel.
317                let wheel_entry = self.build_context.cache().entry(
318                    CacheBucket::Wheels,
319                    WheelCache::Url(&wheel.url).wheel_dir(wheel.name().as_ref()),
320                    wheel.filename.cache_key(),
321                );
322
323                // Download and unzip.
324                match self
325                    .stream_wheel(
326                        wheel.url.raw().clone(),
327                        None,
328                        &wheel.filename,
329                        wheel.size,
330                        &wheel_entry,
331                        dist,
332                        hashes,
333                    )
334                    .await
335                {
336                    Ok(archive) => Ok(LocalWheel {
337                        dist: Dist::Built(dist.clone()),
338                        archive: self
339                            .build_context
340                            .cache()
341                            .archive(&archive.id)
342                            .into_boxed_path(),
343                        hashes: archive.hashes,
344                        filename: wheel.filename.clone(),
345                        cache: CacheInfo::default(),
346                        build: None,
347                    }),
348                    Err(Error::Extract(name, err)) => {
349                        if err.is_http_streaming_unsupported() {
350                            warn!(
351                                "Streaming unsupported for {dist}; downloading wheel to disk ({err})"
352                            );
353                        } else if err.is_http_streaming_failed() {
354                            warn!("Streaming failed for {dist}; downloading wheel to disk ({err})");
355                        } else {
356                            return Err(Error::Extract(name, err));
357                        }
358
359                        // If the request failed because streaming was unsupported or failed,
360                        // download the wheel directly.
361                        let archive = self
362                            .download_wheel(
363                                wheel.url.raw().clone(),
364                                None,
365                                &wheel.filename,
366                                wheel.size,
367                                &wheel_entry,
368                                dist,
369                                hashes,
370                            )
371                            .await?;
372                        Ok(LocalWheel {
373                            dist: Dist::Built(dist.clone()),
374                            archive: self
375                                .build_context
376                                .cache()
377                                .archive(&archive.id)
378                                .into_boxed_path(),
379                            hashes: archive.hashes,
380                            filename: wheel.filename.clone(),
381                            cache: CacheInfo::default(),
382                            build: None,
383                        })
384                    }
385                    Err(err) => Err(err),
386                }
387            }
388
389            BuiltDist::GitPath(wheel) => {
390                // Fetch the Git repository.
391                let fetch = self
392                    .build_context
393                    .git()
394                    .fetch(
395                        &wheel.git,
396                        self.client.unmanaged.git_http_settings(wheel.git.url()),
397                        self.build_context.cache().bucket(CacheBucket::Git),
398                        self.reporter.clone().map(<dyn Reporter>::into_git_reporter),
399                    )
400                    .await?;
401
402                if wheel.git.lfs().enabled() && !fetch.lfs_ready() {
403                    if GIT_LFS.is_err() {
404                        return Err(Error::MissingWheelGitLfsArtifacts(
405                            wheel.url.to_url(),
406                            GitError::GitLfsNotFound,
407                        ));
408                    }
409                    return Err(Error::MissingWheelGitLfsArtifacts(
410                        wheel.url.to_url(),
411                        GitError::GitLfsNotConfigured,
412                    ));
413                }
414
415                let git_sha = fetch.git().precise().expect("Exact commit after checkout");
416                let cache_entry = self.build_context.cache().entry(
417                    CacheBucket::Wheels,
418                    WheelCache::Git(&wheel.url, git_sha.as_short_str()).root(),
419                    wheel.filename.stem(),
420                );
421
422                let install_path = fetch.path().join(&wheel.install_path);
423
424                self.load_wheel(&install_path, &wheel.filename, cache_entry, dist, hashes)
425                    .await
426            }
427
428            BuiltDist::Path(wheel) => {
429                let cache_entry = self.build_context.cache().entry(
430                    CacheBucket::Wheels,
431                    WheelCache::Url(&wheel.url).wheel_dir(wheel.name().as_ref()),
432                    wheel.filename.cache_key(),
433                );
434
435                self.load_wheel(
436                    &wheel.install_path,
437                    &wheel.filename,
438                    cache_entry,
439                    dist,
440                    hashes,
441                )
442                .await
443            }
444        }
445    }
446
447    /// Convert a source distribution into a wheel, fetching it from the cache or building it if
448    /// necessary.
449    ///
450    /// The returned wheel is guaranteed to come from a distribution with a matching hash, and
451    /// no build processes will be executed for distributions with mismatched hashes.
452    async fn build_wheel(
453        &self,
454        dist: &SourceDist,
455        tags: &Tags,
456        hashes: ArchiveHashPolicy<'_>,
457    ) -> Result<LocalWheel, Error> {
458        let built_wheel = self
459            .builder
460            .download_and_build(&BuildableSource::Dist(dist), tags, hashes, &self.client)
461            .boxed_local()
462            .await?;
463
464        // Check that the wheel is compatible with its install target.
465        //
466        // When building a build dependency for a cross-install, the build dependency needs
467        // to install and run on the host instead of the target. In this case the `tags` are already
468        // for the host instead of the target, so this check passes.
469        if !built_wheel.filename.is_compatible(tags) {
470            return if tags.is_cross() {
471                Err(Error::BuiltWheelIncompatibleTargetPlatform {
472                    filename: built_wheel.filename,
473                    python_platform: tags.python_platform().clone(),
474                    python_version: PythonVersion {
475                        version: tags.python_version(),
476                        variant: if tags.is_freethreaded() {
477                            PythonVariant::Freethreaded
478                        } else {
479                            PythonVariant::Default
480                        },
481                    },
482                })
483            } else {
484                Err(Error::BuiltWheelIncompatibleHostPlatform {
485                    filename: built_wheel.filename,
486                    python_platform: tags.python_platform().clone(),
487                    python_version: PythonVersion {
488                        version: tags.python_version(),
489                        variant: if tags.is_freethreaded() {
490                            PythonVariant::Freethreaded
491                        } else {
492                            PythonVariant::Default
493                        },
494                    },
495                })
496            };
497        }
498
499        // Acquire the advisory lock.
500        let wheel_entry = CacheEntry::from_path(built_wheel.target.as_ref());
501        let _lock = Self::lock_wheel(&wheel_entry, &built_wheel.filename).await?;
502
503        // If the wheel was unzipped previously, respect it. Source distributions are
504        // cached under a unique revision ID, so unzipped directories are never stale.
505        match self.build_context.cache().resolve_link(&built_wheel.target) {
506            Ok(archive) => {
507                return Ok(LocalWheel {
508                    dist: Dist::Source(dist.clone()),
509                    archive: archive.into_boxed_path(),
510                    filename: built_wheel.filename,
511                    hashes: built_wheel.hashes,
512                    cache: built_wheel.cache_info,
513                    build: Some(built_wheel.build_info),
514                });
515            }
516            Err(err) if err.kind() == io::ErrorKind::NotFound => {}
517            Err(err) => return Err(Error::CacheRead(err)),
518        }
519
520        // Otherwise, unzip the wheel.
521        let id = self
522            .unzip_wheel(
523                &built_wheel.path,
524                &built_wheel.target,
525                DistRef::Source(dist),
526            )
527            .await?;
528
529        Ok(LocalWheel {
530            dist: Dist::Source(dist.clone()),
531            archive: self.build_context.cache().archive(&id).into_boxed_path(),
532            hashes: built_wheel.hashes,
533            filename: built_wheel.filename,
534            cache: built_wheel.cache_info,
535            build: Some(built_wheel.build_info),
536        })
537    }
538
539    /// Fetch wheel metadata, along with any hashes requested for resolution.
540    ///
541    /// Wheel archive hash validation is deferred to installation. Metadata sidecar hashes are
542    /// checked by the client when downloading sidecars with index-provided hashes.
543    async fn get_wheel_metadata(
544        &self,
545        dist: &BuiltDist,
546        hashes: MetadataHashPolicy<'_>,
547    ) -> Result<ArchiveMetadata, Error> {
548        let hash_policy = match hashes.validation {
549            HashValidation::None => {
550                let compute_hashes = match hashes.collection {
551                    HashCollection::None => false,
552                    HashCollection::Url | HashCollection::All => match dist {
553                        BuiltDist::Registry(dist) => {
554                            // Generate missing hashes for indexes and `--find-links` without hashes.
555                            // This hashes only the selected wheel, not every distribution for the version.
556                            hashes.collection == HashCollection::All
557                                && dist.best_wheel().file.hashes.is_empty()
558                        }
559                        BuiltDist::DirectUrl(dist) => parse_url_hashes(&dist.url).is_none(),
560                        BuiltDist::Path(_) | BuiltDist::GitPath(_) => true,
561                    },
562                };
563                if compute_hashes {
564                    ArchiveHashPolicy::Generate
565                } else {
566                    ArchiveHashPolicy::None
567                }
568            }
569            HashValidation::Any(_) | HashValidation::All(_) => hashes.validation.into(),
570        };
571
572        // Fetch the entire wheel only when we need to compute a hash for resolution.
573        // TODO(charlie): Request the hashes via a separate method, to reduce the coupling in this API.
574        if hash_policy == ArchiveHashPolicy::Generate {
575            let wheel = self.get_wheel(dist, hash_policy).await?;
576            // If the metadata was provided by the user directly, prefer it.
577            let metadata = if let Some(metadata) = self
578                .build_context
579                .dependency_metadata()
580                .get(dist.name(), Some(dist.version()))
581            {
582                metadata
583            } else {
584                wheel.metadata()?
585            };
586            let hashes = wheel.hashes;
587            return Ok(ArchiveMetadata {
588                metadata: Metadata::from_metadata23(metadata),
589                hashes,
590            });
591        }
592
593        // If the metadata was provided by the user directly, prefer it.
594        if let Some(metadata) = self
595            .build_context
596            .dependency_metadata()
597            .get(dist.name(), Some(dist.version()))
598        {
599            return Ok(ArchiveMetadata::from_metadata23(metadata));
600        }
601
602        let result = self
603            .client
604            .managed(|client| {
605                client
606                    .wheel_metadata(
607                        dist,
608                        self.build_context.git(),
609                        self.build_context.capabilities(),
610                        self.reporter.clone().map(<dyn Reporter>::into_git_reporter),
611                    )
612                    .boxed_local()
613            })
614            .await;
615
616        match result {
617            Ok(metadata) => {
618                // Validate that the metadata is consistent with the distribution.
619                Ok(ArchiveMetadata::from_metadata23(metadata))
620            }
621            Err(err) if err.is_http_streaming_unsupported() => {
622                warn!(
623                    "Streaming unsupported when fetching metadata for {dist}; downloading wheel directly ({err})"
624                );
625
626                // If the request failed due to an error that could be resolved by
627                // downloading the wheel directly, try that.
628                let wheel = self.get_wheel(dist, hash_policy).await?;
629                let metadata = wheel.metadata()?;
630                let hashes = wheel.hashes;
631                Ok(ArchiveMetadata {
632                    metadata: Metadata::from_metadata23(metadata),
633                    hashes,
634                })
635            }
636            Err(err) => Err(err.into()),
637        }
638    }
639
640    /// Build the wheel metadata for a source distribution, or fetch it from the cache if possible.
641    ///
642    /// Requested hashes are validated before executing a build backend. User-provided metadata
643    /// can avoid downloading or validating the source archive.
644    pub async fn build_wheel_metadata(
645        &self,
646        source: &BuildableSource<'_>,
647        hashes: MetadataHashPolicy<'_>,
648    ) -> Result<ArchiveMetadata, Error> {
649        // If the metadata was provided by the user directly, prefer it.
650        if let Some(dist) = source.as_dist() {
651            if let Some(metadata) = self
652                .build_context
653                .dependency_metadata()
654                .get(dist.name(), dist.version())
655            {
656                // If we skipped the build, we should still resolve any Git dependencies to precise
657                // commits.
658                self.builder.resolve_revision(source, &self.client).await?;
659
660                return Ok(ArchiveMetadata::from_metadata23(metadata));
661            }
662        }
663
664        let url_hashes = if let BuildableSource::Dist(SourceDist::DirectUrl(dist)) = source {
665            parse_url_hashes(&dist.url)
666        } else if let BuildableSource::Url(SourceUrl::Direct(url)) = source {
667            parse_url_hashes(url.url)
668        } else {
669            None
670        };
671
672        let build_hash_policy = match hashes.validation {
673            HashValidation::None => match hashes.collection {
674                HashCollection::None => ArchiveHashPolicy::None,
675                // If resolving metadata requires a build, validate any URL hash before executing
676                // the backend, even when the caller only requested hash collection.
677                HashCollection::Url | HashCollection::All => match url_hashes.as_ref() {
678                    Some(digests) => ArchiveHashPolicy::All(digests.as_slice()),
679                    None => ArchiveHashPolicy::Generate,
680                },
681            },
682            HashValidation::Any(_) | HashValidation::All(_) => hashes.validation.into(),
683        };
684        let metadata = self
685            .builder
686            .download_and_build_metadata(source, build_hash_policy, &self.client)
687            .boxed_local()
688            .await?;
689
690        Ok(metadata)
691    }
692
693    /// Return the [`RequiresDist`] from a `pyproject.toml`, if it can be statically extracted.
694    pub async fn requires_dist(
695        &self,
696        path: &Path,
697        pyproject_toml: &PyProjectToml,
698    ) -> Result<Option<RequiresDist>, Error> {
699        self.builder
700            .source_tree_requires_dist(
701                path,
702                pyproject_toml,
703                self.client.unmanaged.credentials_cache(),
704            )
705            .await
706    }
707
708    /// Stream a wheel from a URL, unzipping it into the cache as it's downloaded.
709    ///
710    /// Note that `progress_size_hint` is a size hint for the progress reporter,
711    /// and is not guaranteed to be the true size of the wheel if/when fetched from the
712    /// origin.
713    async fn stream_wheel(
714        &self,
715        url: DisplaySafeUrl,
716        index: Option<&IndexUrl>,
717        filename: &WheelFilename,
718        progress_size_hint: Option<u64>,
719        wheel_entry: &CacheEntry,
720        dist: &BuiltDist,
721        hashes: ArchiveHashPolicy<'_>,
722    ) -> Result<Archive, Error> {
723        // Sometimes we can promote the size hint to a trusted effective size.
724        let expected_size = match dist {
725            BuiltDist::Registry(dist) if dist.best_wheel().size_is_authoritative => {
726                progress_size_hint
727            }
728            BuiltDist::DirectUrl(_) => progress_size_hint,
729            _ => None,
730        };
731
732        // Acquire an advisory lock, to guard against concurrent writes.
733        let _lock = Self::lock_wheel(wheel_entry, filename).await?;
734
735        // Create an entry for the HTTP cache.
736        let http_entry = wheel_entry.with_file(format!("{}.http", filename.cache_key()));
737
738        let download = |response: reqwest::Response, _: &mut RetryState| {
739            async {
740                let progress_size_hint = progress_size_hint.or_else(|| content_length(&response));
741
742                let progress = self.reporter.as_ref().map(|reporter| {
743                    (
744                        reporter,
745                        reporter.on_download_start(dist.name(), progress_size_hint),
746                    )
747                });
748
749                let reader = response
750                    .bytes_stream()
751                    .map_err(|err| self.handle_response_errors(err))
752                    .into_async_read();
753
754                // Create a hasher for each hash algorithm.
755                let algorithms = http_hash_algorithms(hashes);
756                let mut hashers = algorithms.into_iter().map(Hasher::from).collect::<Vec<_>>();
757                let mut hasher = uv_extract::hash::HashReader::new(reader.compat(), &mut hashers);
758
759                // Download and unzip the wheel to a temporary directory.
760                let extractor = WheelExtractor::new(
761                    self.build_context.cache().root(),
762                    self.content_addressed_cache,
763                )
764                .map_err(Error::CacheWrite)?;
765
766                let mut extracted = match progress {
767                    Some((reporter, progress)) => {
768                        let mut reader = ProgressReader::new(&mut hasher, progress, &**reporter);
769                        extractor
770                            .extract_streaming(&mut reader)
771                            .await
772                            .map_err(|err| Error::Extract(filename.to_string(), err))?
773                    }
774                    None => extractor
775                        .extract_streaming(&mut hasher)
776                        .await
777                        .map_err(|err| Error::Extract(filename.to_string(), err))?,
778                };
779                // Exhaust the reader to compute the hashes.
780                hasher.finish().await.map_err(Error::HashExhaustion)?;
781                let actual_size = hasher.bytes_read();
782                if let Some(expected) = expected_size
783                    && actual_size != expected
784                {
785                    return Err(Error::MismatchedSize {
786                        distribution: dist.to_string(),
787                        expected,
788                        actual: actual_size,
789                    });
790                }
791
792                // Before we make the wheel accessible by persisting it, ensure that the RECORD is
793                // valid.
794                extracted.validate_and_heal_record(dist)?;
795
796                // Persist the temporary directory to the directory store.
797                let id = self
798                    .persist_extracted_wheel(extracted, wheel_entry.path())
799                    .await?;
800
801                if let Some((reporter, progress)) = progress {
802                    reporter.on_download_complete(dist.name(), progress);
803                }
804
805                Ok(Archive::new(
806                    id,
807                    hashers.into_iter().map(HashDigest::from).collect(),
808                    filename.clone(),
809                    Some(actual_size),
810                ))
811            }
812            .instrument(info_span!("wheel", wheel = %dist))
813        };
814
815        // Fetch the archive from the cache, or download it if necessary.
816        let req = self.request(url.clone())?;
817
818        // Determine the cache control policy for the URL.
819        let cache_control = match self.client.unmanaged.connectivity() {
820            Connectivity::Online
821                if let Some(header) = index.and_then(|index| {
822                    self.build_context
823                        .locations()
824                        .artifact_cache_control_for(index)
825                }) =>
826            {
827                CacheControl::Override(header)
828            }
829            Connectivity::Online => CacheControl::from(
830                self.build_context
831                    .cache()
832                    .freshness(&http_entry, Some(&filename.name), None)
833                    .map_err(Error::CacheRead)?,
834            ),
835            Connectivity::Offline => CacheControl::AllowStale,
836        };
837
838        let archive = self
839            .client
840            .managed(|client| {
841                client.cached_client().get_serde_with_retry(
842                    req,
843                    &http_entry,
844                    cache_control.clone(),
845                    download,
846                )
847            })
848            .await
849            .map_err(|err| match err {
850                CachedClientError::Callback { err, .. } => err,
851                CachedClientError::Client(err) => Error::Client(err),
852            })?;
853
854        if let (Some(expected), Some(actual)) = (expected_size, archive.size)
855            && expected != actual
856        {
857            return Err(Error::MismatchedSize {
858                distribution: dist.to_string(),
859                expected,
860                actual,
861            });
862        }
863
864        // If the archive is missing the required hashes or size, or has since been removed, force a refresh.
865        let archive = Some(archive)
866            .filter(|archive| archive.has_digests(hashes))
867            .filter(|archive| archive.exists(self.build_context.cache()))
868            .filter(|archive| expected_size.is_none() || archive.size.is_some());
869
870        let archive = if let Some(archive) = archive {
871            archive
872        } else {
873            self.client
874                .managed(async |client| {
875                    client
876                        .cached_client()
877                        .skip_cache_with_retry(
878                            self.request(url)?,
879                            &http_entry,
880                            cache_control,
881                            download,
882                        )
883                        .await
884                        .map_err(|err| match err {
885                            CachedClientError::Callback { err, .. } => err,
886                            CachedClientError::Client(err) => Error::Client(err),
887                        })
888                })
889                .await?
890        };
891
892        Ok(archive)
893    }
894
895    /// Download a wheel from a URL, then unzip it into the cache.
896    ///
897    /// Note that, like [`Self::stream_wheel`], `progress_size_hint` is
898    /// a size hint for the progress reporter, and is not guaranteed to be the size
899    /// of the wheel if/when fetched from the origin.
900    async fn download_wheel(
901        &self,
902        url: DisplaySafeUrl,
903        index: Option<&IndexUrl>,
904        filename: &WheelFilename,
905        progress_size_hint: Option<u64>,
906        wheel_entry: &CacheEntry,
907        dist: &BuiltDist,
908        hashes: ArchiveHashPolicy<'_>,
909    ) -> Result<Archive, Error> {
910        // Sometimes we can promote the size hint to a trusted effective size.
911        let expected_size = match dist {
912            BuiltDist::Registry(dist) if dist.best_wheel().size_is_authoritative => {
913                progress_size_hint
914            }
915            BuiltDist::DirectUrl(_) => progress_size_hint,
916            _ => None,
917        };
918
919        // Acquire an advisory lock, to guard against concurrent writes.
920        let _lock = Self::lock_wheel(wheel_entry, filename).await?;
921
922        // Create an entry for the HTTP cache.
923        let http_entry = wheel_entry.with_file(format!("{}.http", filename.cache_key()));
924
925        let download_url = url.clone();
926
927        let download = async |response, retry_state: &mut RetryState| {
928            self.download_wheel_response(
929                response,
930                &download_url,
931                retry_state,
932                filename,
933                progress_size_hint,
934                expected_size,
935                wheel_entry,
936                dist,
937                hashes,
938            )
939            .instrument(info_span!("wheel", wheel = %dist))
940            .await
941        };
942
943        // Fetch the archive from the cache, or download it if necessary.
944        let req = self.request(url.clone())?;
945
946        // Determine the cache control policy for the URL.
947        let cache_control = match self.client.unmanaged.connectivity() {
948            Connectivity::Online
949                if let Some(header) = index.and_then(|index| {
950                    self.build_context
951                        .locations()
952                        .artifact_cache_control_for(index)
953                }) =>
954            {
955                CacheControl::Override(header)
956            }
957            Connectivity::Online => CacheControl::from(
958                self.build_context
959                    .cache()
960                    .freshness(&http_entry, Some(&filename.name), None)
961                    .map_err(Error::CacheRead)?,
962            ),
963            Connectivity::Offline => CacheControl::AllowStale,
964        };
965
966        let archive = self
967            .client
968            .managed(|client| {
969                client.cached_client().get_serde_with_retry(
970                    req,
971                    &http_entry,
972                    cache_control.clone(),
973                    download,
974                )
975            })
976            .await
977            .map_err(|err| match err {
978                CachedClientError::Callback { err, .. } => err,
979                CachedClientError::Client(err) => Error::Client(err),
980            })?;
981
982        if let (Some(expected), Some(actual)) = (expected_size, archive.size)
983            && expected != actual
984        {
985            return Err(Error::MismatchedSize {
986                distribution: dist.to_string(),
987                expected,
988                actual,
989            });
990        }
991
992        // If the archive is missing the required hashes or size, or has since been removed, force a refresh.
993        let archive = Some(archive)
994            .filter(|archive| archive.has_digests(hashes))
995            .filter(|archive| archive.exists(self.build_context.cache()))
996            .filter(|archive| expected_size.is_none() || archive.size.is_some());
997
998        let archive = if let Some(archive) = archive {
999            archive
1000        } else {
1001            self.client
1002                .managed(async |client| {
1003                    client
1004                        .cached_client()
1005                        .skip_cache_with_retry(
1006                            self.request(url)?,
1007                            &http_entry,
1008                            cache_control,
1009                            download,
1010                        )
1011                        .await
1012                        .map_err(|err| match err {
1013                            CachedClientError::Callback { err, .. } => err,
1014                            CachedClientError::Client(err) => Error::Client(err),
1015                        })
1016                })
1017                .await?
1018        };
1019
1020        Ok(archive)
1021    }
1022
1023    /// Download and extract a wheel from an HTTP response.
1024    ///
1025    /// This helper is called by [`Self::download_wheel`] and handles partial content/resumptions
1026    /// of interrupted downloads if the origin supports it. Hard failures are propagated
1027    /// back to the caller and thus to our general retry machinery.
1028    ///
1029    /// The caller is responsible for obtaining a lock on the wheel cache.
1030    async fn download_wheel_response(
1031        &self,
1032        mut response: reqwest::Response,
1033        url: &DisplaySafeUrl,
1034        retry_state: &mut RetryState,
1035        filename: &WheelFilename,
1036        progress_size_hint: Option<u64>,
1037        expected_size: Option<u64>,
1038        wheel_entry: &CacheEntry,
1039        dist: &BuiltDist,
1040        hashes: ArchiveHashPolicy<'_>,
1041    ) -> Result<Archive, Error> {
1042        let progress_size_hint = progress_size_hint.or_else(|| content_length(&response));
1043        let mut download_size = content_length(&response).or(expected_size);
1044
1045        let progress = self.reporter.as_ref().map(|reporter| {
1046            (
1047                reporter,
1048                reporter.on_download_start(dist.name(), progress_size_hint),
1049            )
1050        });
1051
1052        let algorithms = http_hash_algorithms(hashes);
1053
1054        // Download the wheel to a temporary file.
1055        let temp_file =
1056            tempfile::tempfile_in(self.build_context.cache().root()).map_err(Error::CacheWrite)?;
1057        let mut writer = tokio::io::BufWriter::new(fs_err::tokio::File::from_std(
1058            // It's an unnamed file on Linux so that's the best approximation.
1059            fs_err::File::from_parts(temp_file, self.build_context.cache().root()),
1060        ));
1061
1062        // States for the download loop below.
1063        let mut hashers = algorithms.into_iter().map(Hasher::from).collect::<Vec<_>>();
1064        // The total number of bytes retrieved, accumulated over individual requests.
1065        // Note that this is *not* the same as the number of bytes actually written
1066        // to the temporary file, since a copy from the response to the file can fail.
1067        let mut bytes_retrieved = 0;
1068        // The most recent range request's starting offset.
1069        let mut resumed_at = None;
1070        // The `Content-Range` that the download was last resumed at.
1071        let mut resumed_range: Option<ContentRangeBytes> = None;
1072
1073        // This loop is where we handle resumption of interrupted downloads, as well as
1074        // range requests (if the origin supports them).
1075        //
1076        // Errors returned from this loop reach the outer retry classifier, which may restart
1077        // the full download.
1078        loop {
1079            // Reject conflicting full-response lengths before reading the body. A range response's
1080            // Content-Length describes only that range, so it cannot be compared to the wheel size.
1081            if response.status() == reqwest::StatusCode::OK
1082                && let (Some(expected), Some(actual)) = (expected_size, content_length(&response))
1083                && expected != actual
1084            {
1085                return Err(Error::MismatchedContentLength {
1086                    distribution: dist.to_string(),
1087                    expected,
1088                    actual,
1089                });
1090            }
1091
1092            // Check whether the response indicates range request support. A `206 Partial Content`
1093            // implies range support while an `Accept-Ranges: bytes` header explicitly advertises it.
1094            let supports_range_requests = response.status() == reqwest::StatusCode::PARTIAL_CONTENT
1095                || response
1096                    .headers()
1097                    .get(reqwest::header::ACCEPT_RANGES)
1098                    .is_some_and(|value| value == "bytes");
1099
1100            // A server can advertise range requests but ignore one. In that case, the
1101            // response is a complete download and must replace the partial bytes.
1102            let replaces_partial_download =
1103                resumed_at.is_some() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT;
1104            if replaces_partial_download {
1105                writer
1106                    .get_mut()
1107                    .set_len(0)
1108                    .await
1109                    .map_err(Error::CacheWrite)?;
1110                writer
1111                    .seek(io::SeekFrom::Start(0))
1112                    .await
1113                    .map_err(Error::CacheWrite)?;
1114                hashers = http_hash_algorithms(hashes)
1115                    .into_iter()
1116                    .map(Hasher::from)
1117                    .collect();
1118                bytes_retrieved = 0;
1119            }
1120
1121            let reader = response
1122                .bytes_stream()
1123                .map_err(|err| self.handle_response_errors(err))
1124                .into_async_read();
1125            let mut hasher = uv_extract::hash::HashReader::new(reader.compat(), &mut hashers);
1126
1127            // Drain the response. This could be a partial response or a full one.
1128            // Note that the partial response here can take several forms: it can be an interrupted
1129            // request *or* it can be a `206 Partial Content`.
1130            let copy_result = match progress {
1131                Some((reporter, progress)) => {
1132                    // Wrap the reader in a progress reporter. This will report 100%
1133                    // progress once the download is complete, before the wheel is unzipped.
1134                    let mut reader = ProgressReader::new(&mut hasher, progress, &**reporter);
1135
1136                    tokio::io::copy(&mut reader, &mut writer)
1137                        .await
1138                        .map_err(Error::CacheWrite)
1139                }
1140                None => tokio::io::copy(&mut hasher, &mut writer)
1141                    .await
1142                    .map_err(Error::CacheWrite),
1143            };
1144
1145            bytes_retrieved += hasher.bytes_read();
1146
1147            let interrupted = copy_result.is_err();
1148            let err = match copy_result {
1149                // Draining the response succeeded. However, the response could be a `206 Partial Content`,
1150                // so we can't assume that we're done.
1151                Ok(_) => {
1152                    // No `resumed_range` means this was a normal full response, so there's
1153                    // no resumption to do. We can leave the loop.
1154                    let Some(range) = resumed_range else {
1155                        break;
1156                    };
1157
1158                    // We should be in sync with the origin. Failure here means that the
1159                    // origin actually sent us an under- or over-length response. Treat this
1160                    // as a non-retryable error, since the HTTP body was received successfully.
1161                    //
1162                    // Byte ranges are inclusive, not exclusive.
1163                    if bytes_retrieved != range.last_byte + 1 {
1164                        return Err(Error::MismatchedRangeSize {
1165                            distribution: dist.to_string(),
1166                            expected: range.last_byte - range.first_byte + 1,
1167                            actual: hasher.bytes_read(),
1168                        });
1169                    }
1170
1171                    // We've successfully performed the download over one or more
1172                    // range requests. We can leave the loop.
1173                    if bytes_retrieved == range.complete_length {
1174                        break;
1175                    }
1176
1177                    // A successful range response may cover only part of the requested
1178                    // bytes. Keep requesting the remainder before extracting the wheel.
1179                    //
1180                    // Observe that we don't return this error; we bind it for handling
1181                    // below.
1182                    Error::CacheWrite(io::Error::new(
1183                        io::ErrorKind::UnexpectedEof,
1184                        "Range response did not complete the download",
1185                    ))
1186                }
1187                // Draining the response failed, potentially because the connection was interrupted.
1188                // Like above, we don't return this error until we know how we want to treat it.
1189                Err(err) => err,
1190            };
1191
1192            // At this point we have *some* error state: either a synthetic error (from a
1193            // partial response that did not complete the download) or a real error (e.g.
1194            // from an interrupted download). We can only resume if certain conditions
1195            // below are met.
1196
1197            // If the response replaces an already in-flight partial download or
1198            // indicates a lack of range-request support, then we can't resume.
1199            // Return the error back to the retry stack.
1200            if replaces_partial_download || !supports_range_requests {
1201                return Err(err);
1202            }
1203
1204            // Some sanity checks: we should have made *some* progress as part of
1205            // partial response, plus our writer's offset should agree with the number
1206            // of bytes retrieved, *and* we should be making forward progress (i.e. not
1207            // resuming behind where we already are).
1208            writer.flush().await.map_err(Error::CacheWrite)?;
1209            let offset = writer
1210                .get_mut()
1211                .stream_position()
1212                .await
1213                .map_err(Error::CacheWrite)?;
1214            if offset == 0
1215                || offset != bytes_retrieved
1216                || resumed_at.is_some_and(|previous| offset <= previous)
1217            {
1218                return Err(err);
1219            }
1220
1221            // Recovering from a failed body consumes the same budget as a full restart.
1222            // A successfully completed range needs no retry, even if more bytes remain.
1223            if interrupted {
1224                let Some(backoff) = retry_state.should_retry(&err, 0) else {
1225                    return Err(err);
1226                };
1227                retry_state.sleep_backoff(backoff).await;
1228            }
1229
1230            // Finally our resumption, which is a range request.
1231            debug!("Resuming download of {url} at byte {offset}");
1232            let resumed_response = retry_state
1233                .send(self.request_with_offset(url.clone(), offset))
1234                .await?;
1235
1236            // A chunked response can fail after all wheel bytes arrive, leaving no satisfiable
1237            // range. Return the original error so the outer retry policy can restart in full.
1238            if resumed_response.status() == reqwest::StatusCode::RANGE_NOT_SATISFIABLE {
1239                debug!("Range not satisfiable while resuming {url}; abandoning resumed download");
1240                return Err(err);
1241            }
1242            resumed_response.error_for_status_ref()?;
1243
1244            resumed_range = if resumed_response.status() == reqwest::StatusCode::PARTIAL_CONTENT {
1245                // The origin honored our range request, so we update `resumed_range`
1246                // for the next iteration.
1247                let Some(range) = content_range(&resumed_response, offset, download_size) else {
1248                    warn!(
1249                        "Invalid range request response from server that declares HTTP range \
1250                         request support, abandoning resumed download: {url}"
1251                    );
1252                    return Err(err);
1253                };
1254                download_size = Some(range.complete_length);
1255                Some(range)
1256            } else {
1257                // The origin is allowed to ignore our range request and send a full response instead.
1258                // That means we have no `resumed_range` to honor on the next iteration.
1259                None
1260            };
1261
1262            response = resumed_response;
1263            resumed_at = Some(offset);
1264        }
1265
1266        // We've left the resumption loop.
1267        // Sanity check: we should have written as many bytes as we expected.
1268        if let Some(expected) = expected_size
1269            && bytes_retrieved != expected
1270        {
1271            return Err(Error::MismatchedSize {
1272                distribution: dist.to_string(),
1273                expected,
1274                actual: bytes_retrieved,
1275            });
1276        }
1277
1278        // Unzip the wheel to a temporary directory.
1279        let extractor = WheelExtractor::new(
1280            self.build_context.cache().root(),
1281            self.content_addressed_cache,
1282        )
1283        .map_err(Error::CacheWrite)?;
1284        let mut file = writer.into_inner();
1285        file.seek(io::SeekFrom::Start(0))
1286            .await
1287            .map_err(Error::CacheWrite)?;
1288
1289        let file = file.into_std().await;
1290        let mut extracted = tokio::task::spawn_blocking(move || extractor.extract_seekable(file))
1291            .await?
1292            .map_err(|err| Error::Extract(filename.to_string(), err))?;
1293        let hashes = hashers.into_iter().map(HashDigest::from).collect();
1294
1295        // Before we make the wheel accessible by persisting it, ensure that the RECORD is
1296        // valid.
1297        extracted.validate_and_heal_record(dist)?;
1298
1299        // Persist the temporary directory to the directory store.
1300        let id = self
1301            .persist_extracted_wheel(extracted, wheel_entry.path())
1302            .await?;
1303
1304        if let Some((reporter, progress)) = progress {
1305            reporter.on_download_complete(dist.name(), progress);
1306        }
1307
1308        Ok(Archive::new(
1309            id,
1310            hashes,
1311            filename.clone(),
1312            Some(bytes_retrieved),
1313        ))
1314    }
1315
1316    /// Load a wheel from a local path.
1317    async fn load_wheel(
1318        &self,
1319        path: &Path,
1320        filename: &WheelFilename,
1321        wheel_entry: CacheEntry,
1322        dist: &BuiltDist,
1323        hashes: ArchiveHashPolicy<'_>,
1324    ) -> Result<LocalWheel, Error> {
1325        // Acquire an advisory lock, to guard against concurrent writes.
1326        let _lock = Self::lock_wheel(&wheel_entry, filename).await?;
1327
1328        // Determine the last-modified time of the wheel.
1329        let modified = Timestamp::from_path(path).map_err(Error::CacheRead)?;
1330
1331        // Attempt to read the archive pointer from the cache.
1332        let pointer_entry = wheel_entry.with_file(format!("{}.rev", filename.cache_key()));
1333        let pointer = PathArchivePointer::read_from(&pointer_entry)?;
1334
1335        // Extract the archive from the pointer.
1336        let archive = pointer
1337            .filter(|pointer| pointer.is_up_to_date(modified))
1338            .map(PathArchivePointer::into_archive)
1339            .filter(|archive| archive.has_digests(hashes));
1340
1341        // If the file is already unzipped, and the cache is up-to-date, return it.
1342        if let Some(archive) = archive {
1343            Ok(LocalWheel {
1344                dist: Dist::Built(dist.clone()),
1345                archive: self
1346                    .build_context
1347                    .cache()
1348                    .archive(&archive.id)
1349                    .into_boxed_path(),
1350                hashes: archive.hashes,
1351                filename: filename.clone(),
1352                cache: CacheInfo::from_timestamp(modified),
1353                build: None,
1354            })
1355        } else if hashes.is_none() {
1356            // Otherwise, unzip the wheel.
1357            let archive = Archive::new(
1358                self.unzip_wheel(path, wheel_entry.path(), DistRef::Built(dist))
1359                    .await?,
1360                HashDigests::empty(),
1361                filename.clone(),
1362                None,
1363            );
1364
1365            // Write the archive pointer to the cache.
1366            let pointer = PathArchivePointer {
1367                timestamp: modified,
1368                archive: archive.clone(),
1369            };
1370            pointer.write_to(&pointer_entry).await?;
1371
1372            Ok(LocalWheel {
1373                dist: Dist::Built(dist.clone()),
1374                archive: self
1375                    .build_context
1376                    .cache()
1377                    .archive(&archive.id)
1378                    .into_boxed_path(),
1379                hashes: archive.hashes,
1380                filename: filename.clone(),
1381                cache: CacheInfo::from_timestamp(modified),
1382                build: None,
1383            })
1384        } else {
1385            // If necessary, compute the hashes of the wheel.
1386            let file = fs_err::tokio::File::open(path)
1387                .await
1388                .map_err(Error::CacheRead)?;
1389            let extractor = WheelExtractor::new(
1390                self.build_context.cache().root(),
1391                self.content_addressed_cache,
1392            )
1393            .map_err(Error::CacheWrite)?;
1394
1395            // Create a hasher for each hash algorithm.
1396            let algorithms = hashes.algorithms();
1397            let mut hashers = algorithms.into_iter().map(Hasher::from).collect::<Vec<_>>();
1398            let mut hasher = uv_extract::hash::HashReader::new(file, &mut hashers);
1399
1400            // Unzip the wheel to a temporary directory.
1401            let mut extracted = extractor
1402                .extract_streaming(&mut hasher)
1403                .await
1404                .map_err(|err| Error::Extract(filename.to_string(), err))?;
1405
1406            // Exhaust the reader to compute the hash.
1407            hasher.finish().await.map_err(Error::HashExhaustion)?;
1408
1409            let hashes = hashers.into_iter().map(HashDigest::from).collect();
1410
1411            // Before we make the wheel accessible by persisting it, ensure that the RECORD is
1412            // valid.
1413            extracted.validate_and_heal_record(dist)?;
1414
1415            // Persist the temporary directory to the directory store.
1416            let id = self
1417                .persist_extracted_wheel(extracted, wheel_entry.path())
1418                .await?;
1419
1420            // Create an archive.
1421            let archive = Archive::new(id, hashes, filename.clone(), None);
1422
1423            // Write the archive pointer to the cache.
1424            let pointer = PathArchivePointer {
1425                timestamp: modified,
1426                archive: archive.clone(),
1427            };
1428            pointer.write_to(&pointer_entry).await?;
1429
1430            Ok(LocalWheel {
1431                dist: Dist::Built(dist.clone()),
1432                archive: self
1433                    .build_context
1434                    .cache()
1435                    .archive(&archive.id)
1436                    .into_boxed_path(),
1437                hashes: archive.hashes,
1438                filename: filename.clone(),
1439                cache: CacheInfo::from_timestamp(modified),
1440                build: None,
1441            })
1442        }
1443    }
1444
1445    /// Unzip a wheel into the cache, returning the path to the unzipped directory.
1446    async fn unzip_wheel(
1447        &self,
1448        path: &Path,
1449        target: &Path,
1450        dist: DistRef<'_>,
1451    ) -> Result<ArchiveId, Error> {
1452        let content_addressed_cache = self.content_addressed_cache;
1453
1454        let mut extracted = tokio::task::spawn_blocking({
1455            let path = path.to_owned();
1456            let root = self.build_context.cache().root().to_path_buf();
1457            move || -> Result<_, Error> {
1458                // Unzip the wheel into a temporary directory.
1459                let extractor = WheelExtractor::new(&root, content_addressed_cache)
1460                    .map_err(Error::CacheWrite)?;
1461                let reader = fs_err::File::open(&path).map_err(Error::CacheWrite)?;
1462                extractor
1463                    .extract_seekable(reader)
1464                    .map_err(|err| Error::Extract(path.to_string_lossy().into_owned(), err))
1465            }
1466        })
1467        .await??;
1468
1469        // Before we make the wheel accessible by persisting it, ensure that the RECORD is valid.
1470        extracted.validate_and_heal_record(dist)?;
1471
1472        // Persist the temporary directory to the directory store.
1473        let id = self.persist_extracted_wheel(extracted, target).await?;
1474
1475        Ok(id)
1476    }
1477
1478    /// Persist an extracted wheel into the archive store.
1479    ///
1480    /// A hash tree makes identical extracted trees converge on one archive entry. Without one,
1481    /// persistence retains the existing behavior of assigning a unique archive ID.
1482    async fn persist_extracted_wheel(
1483        &self,
1484        extracted: ExtractedWheel,
1485        target: &Path,
1486    ) -> Result<ArchiveId, Error> {
1487        let (temp_dir, hashed_wheel) = extracted.into_parts();
1488        let cache = self.build_context.cache();
1489        let (temp_dir, id) = if let Some(HashedWheel { files, tree }) = hashed_wheel {
1490            let digest = DirectoryDigest::from(tree.hash());
1491            let id = ArchiveId::from_digest(digest.into());
1492            let cache = cache.clone();
1493            let temp_dir = tokio::task::spawn_blocking(move || {
1494                persist_archive_files(&cache, temp_dir.path(), &files)
1495                    .map_err(Error::CacheWrite)?;
1496                Ok::<_, Error>(temp_dir)
1497            })
1498            .await??;
1499            (temp_dir, id)
1500        } else {
1501            (temp_dir, ArchiveId::default())
1502        };
1503
1504        cache
1505            .persist_with_id(temp_dir, target, id)
1506            .await
1507            .map_err(Error::CacheWrite)
1508    }
1509
1510    /// Returns a GET [`reqwest::Request`] for the given URL.
1511    fn request(&self, url: DisplaySafeUrl) -> Result<reqwest::Request, reqwest::Error> {
1512        self.client
1513            .unmanaged
1514            .uncached_client(&url)
1515            .get(Url::from(url))
1516            .header(
1517                // `reqwest` defaults to accepting compressed responses.
1518                // Specify identity encoding to get consistent .whl downloading
1519                // behavior from servers. ref: https://github.com/pypa/pip/pull/1688
1520                "accept-encoding",
1521                reqwest::header::HeaderValue::from_static("identity"),
1522            )
1523            .build()
1524    }
1525
1526    /// Build a GET request with a `Range: bytes=<offset>-` header.
1527    ///
1528    /// Used to resume an interrupted download from `offset` bytes into the file.
1529    fn request_with_offset(&self, url: DisplaySafeUrl, offset: u64) -> RequestBuilder<'_> {
1530        self.client
1531            .unmanaged
1532            .uncached_client(&url)
1533            .get(Url::from(url))
1534            .header(
1535                "accept-encoding",
1536                reqwest::header::HeaderValue::from_static("identity"),
1537            )
1538            .header(reqwest::header::RANGE, format!("bytes={offset}-"))
1539    }
1540
1541    /// Return the [`ManagedClient`] used by this resolver.
1542    pub fn client(&self) -> &ManagedClient<'a> {
1543        &self.client
1544    }
1545}
1546
1547/// Share extracted files other than `RECORD` while keeping the unpublished archive complete.
1548fn persist_archive_files(cache: &Cache, archive: &Path, files: &[HashedFile]) -> io::Result<()> {
1549    initialize_rayon_once();
1550    let targets = files
1551        .par_iter()
1552        // Keep RECORD private, since it may have been healed after hashing.
1553        .filter(|file| !file.path().ends_with("RECORD"))
1554        .map(|file| {
1555            let id = ArchiveFileId::from_digest(&file.object_digest_hex());
1556            (archive.join(file.path()), cache.archive_file(&id))
1557        })
1558        .collect::<Vec<_>>();
1559
1560    // Group files by shard so its directory is created once and its files are linked by the
1561    // same worker, avoiding contention between workers on each shard directory.
1562    let mut shards: FxHashMap<&Path, Vec<_>> = FxHashMap::default();
1563    for (source, target) in &targets {
1564        let Some(parent) = target.parent() else {
1565            return Err(io::Error::new(
1566                io::ErrorKind::InvalidInput,
1567                "archive file path must have a parent directory",
1568            ));
1569        };
1570        shards.entry(parent).or_default().push((source, target));
1571    }
1572
1573    let mut shards = shards
1574        .into_iter()
1575        .map(|(parent, files)| (parent, files, Ok(())))
1576        .collect::<Vec<_>>();
1577    // Start larger shards first so their work can overlap the remaining directory creation.
1578    shards.sort_unstable_by_key(|(_, files, _)| Reverse(files.len()));
1579
1580    // Creating shards concurrently contends on their shared parent. Keep creation on this
1581    // thread, while workers link files in the shards that are already available.
1582    in_place_scope(|scope| -> io::Result<()> {
1583        for (parent, files, result) in &mut shards {
1584            fs_err::create_dir_all(parent)?;
1585            scope.spawn(move |_| {
1586                *result = files
1587                    .iter()
1588                    .try_for_each(|(source, target)| persist_archive_file(source, target));
1589            });
1590        }
1591        Ok(())
1592    })?;
1593
1594    shards.into_iter().try_for_each(|(_, _, result)| result)
1595}
1596
1597/// Publish a shared object and retain a hardlink in the archive, with a copy fallback.
1598fn persist_archive_file(src: &Path, dst: &Path) -> io::Result<()> {
1599    // The shard already exists, and most objects are new, so try linking before checking for an
1600    // existing object. This avoids an extra filesystem lookup for every new object.
1601    match fs_err::hard_link(src, dst) {
1602        Ok(()) => return Ok(()),
1603        Err(_) if dst.try_exists()? => {}
1604        Err(_) => return uv_fs::copy_atomic_sync(src, dst),
1605    }
1606
1607    // This archive is still private, so it is safe to replace its extracted copy before publication.
1608    if let Err(err) = fs_err::remove_file(src)
1609        && err.kind() != io::ErrorKind::NotFound
1610    {
1611        return Err(err);
1612    }
1613
1614    fs_err::hard_link(dst, src).or_else(|_| uv_fs::copy_atomic_sync(dst, src))
1615}
1616
1617/// A wrapper around `RegistryClient` that manages a concurrency limit.
1618pub struct ManagedClient<'a> {
1619    pub unmanaged: &'a RegistryClient,
1620    control: Arc<Semaphore>,
1621}
1622
1623impl<'a> ManagedClient<'a> {
1624    /// Create a new `ManagedClient` using the given client and concurrency semaphore.
1625    fn new(client: &'a RegistryClient, control: Arc<Semaphore>) -> Self {
1626        ManagedClient {
1627            unmanaged: client,
1628            control,
1629        }
1630    }
1631
1632    /// Perform a request using the client, respecting the concurrency limit.
1633    ///
1634    /// If the concurrency limit has been reached, this method will wait until a pending
1635    /// operation completes before executing the closure.
1636    pub async fn managed<F, T>(&self, f: impl FnOnce(&'a RegistryClient) -> F) -> T
1637    where
1638        F: Future<Output = T>,
1639    {
1640        let _permit = self.control.acquire().await.unwrap();
1641        f(self.unmanaged).await
1642    }
1643
1644    /// Perform a request using a client that internally manages the concurrency limit.
1645    ///
1646    /// The callback is passed the client and a semaphore. It must acquire the semaphore before
1647    /// any request through the client and drop it after.
1648    ///
1649    /// This method serves as an escape hatch for functions that may want to send multiple requests
1650    /// in parallel.
1651    pub async fn manual<F, T>(&'a self, f: impl FnOnce(&'a RegistryClient, &'a Semaphore) -> F) -> T
1652    where
1653        F: Future<Output = T>,
1654    {
1655        f(self.unmanaged, &self.control).await
1656    }
1657}
1658
1659/// Returns the value of the `Content-Length` header from the [`reqwest::Response`], if present.
1660fn content_length(response: &reqwest::Response) -> Option<u64> {
1661    response
1662        .headers()
1663        .get(reqwest::header::CONTENT_LENGTH)
1664        .and_then(|val| val.to_str().ok())
1665        .and_then(|val| val.parse::<u64>().ok())
1666}
1667
1668/// Return the bounds of a range response starting at `offset` with a known complete length.
1669fn content_range(
1670    response: &reqwest::Response,
1671    offset: u64,
1672    download_size: Option<u64>,
1673) -> Option<ContentRangeBytes> {
1674    let range = response
1675        .headers()
1676        .get(reqwest::header::CONTENT_RANGE)
1677        .and_then(|value| value.to_str().ok())
1678        .and_then(ContentRange::parse)?;
1679
1680    let range = match range {
1681        ContentRange::Bytes(range) => range,
1682        ContentRange::UnboundBytes(ContentRangeUnbound {
1683            first_byte,
1684            last_byte,
1685        }) => ContentRangeBytes {
1686            first_byte,
1687            last_byte,
1688            complete_length: download_size?,
1689        },
1690        ContentRange::Unsatisfied(_) => return None,
1691    };
1692
1693    if range.first_byte != offset
1694        || range.last_byte >= range.complete_length
1695        || download_size.is_some_and(|size| size != range.complete_length)
1696    {
1697        return None;
1698    }
1699
1700    Some(range)
1701}
1702
1703/// An asynchronous reader that reports progress as bytes are read.
1704struct ProgressReader<'a, R> {
1705    reader: R,
1706    index: usize,
1707    reporter: &'a dyn Reporter,
1708}
1709
1710impl<'a, R> ProgressReader<'a, R> {
1711    /// Create a new [`ProgressReader`] that wraps another reader.
1712    fn new(reader: R, index: usize, reporter: &'a dyn Reporter) -> Self {
1713        Self {
1714            reader,
1715            index,
1716            reporter,
1717        }
1718    }
1719}
1720
1721impl<R> AsyncRead for ProgressReader<'_, R>
1722where
1723    R: AsyncRead + Unpin,
1724{
1725    fn poll_read(
1726        mut self: Pin<&mut Self>,
1727        cx: &mut Context<'_>,
1728        buf: &mut ReadBuf<'_>,
1729    ) -> Poll<io::Result<()>> {
1730        Pin::new(&mut self.as_mut().reader)
1731            .poll_read(cx, buf)
1732            .map_ok(|()| {
1733                self.reporter
1734                    .on_download_progress(self.index, buf.filled().len() as u64);
1735            })
1736    }
1737}
1738
1739/// A pointer to an archive in the cache, fetched from an HTTP archive.
1740///
1741/// Encoded with `MsgPack`, and represented on disk by a `.http` file.
1742#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1743pub struct HttpArchivePointer {
1744    archive: Archive,
1745}
1746
1747impl HttpArchivePointer {
1748    /// Read an [`HttpArchivePointer`] from the cache.
1749    pub fn read_from(path: impl AsRef<Path>) -> Result<Option<Self>, Error> {
1750        match fs_err::File::open(path.as_ref()) {
1751            Ok(file) => {
1752                let data = DataWithCachePolicy::from_reader(file)?.data;
1753                let archive = rmp_serde::from_slice::<Archive>(&data)?;
1754                Ok(Some(Self { archive }))
1755            }
1756            Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
1757            Err(err) => Err(Error::CacheRead(err)),
1758        }
1759    }
1760
1761    /// Return the [`Archive`] from the pointer.
1762    pub fn into_archive(self) -> Archive {
1763        self.archive
1764    }
1765
1766    /// Return the [`CacheInfo`] from the pointer.
1767    pub fn to_cache_info(&self) -> CacheInfo {
1768        CacheInfo::default()
1769    }
1770
1771    /// Return the [`BuildInfo`] from the pointer.
1772    pub fn to_build_info(&self) -> Option<BuildInfo> {
1773        None
1774    }
1775}
1776
1777/// A pointer to an archive in the cache, fetched from a local path.
1778///
1779/// Encoded with `MsgPack`, and represented on disk by a `.rev` file.
1780#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1781pub struct PathArchivePointer {
1782    timestamp: Timestamp,
1783    archive: Archive,
1784}
1785
1786impl PathArchivePointer {
1787    /// Read an [`PathArchivePointer`] from the cache.
1788    pub fn read_from(path: impl AsRef<Path>) -> Result<Option<Self>, Error> {
1789        match fs_err::read(path) {
1790            Ok(cached) => Ok(Some(rmp_serde::from_slice::<Self>(&cached)?)),
1791            Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
1792            Err(err) => Err(Error::CacheRead(err)),
1793        }
1794    }
1795
1796    /// Write an [`PathArchivePointer`] to the cache.
1797    async fn write_to(&self, entry: &CacheEntry) -> Result<(), Error> {
1798        write_atomic(entry.path(), rmp_serde::to_vec(&self)?)
1799            .await
1800            .map_err(Error::CacheWrite)
1801    }
1802
1803    /// Returns `true` if the archive is up-to-date with the given modified timestamp.
1804    pub fn is_up_to_date(&self, modified: Timestamp) -> bool {
1805        self.timestamp == modified
1806    }
1807
1808    /// Return the [`Archive`] from the pointer.
1809    pub fn into_archive(self) -> Archive {
1810        self.archive
1811    }
1812
1813    /// Return the [`CacheInfo`] from the pointer.
1814    pub fn to_cache_info(&self) -> CacheInfo {
1815        CacheInfo::from_timestamp(self.timestamp)
1816    }
1817
1818    /// Return the [`BuildInfo`] from the pointer.
1819    pub fn to_build_info(&self) -> Option<BuildInfo> {
1820        None
1821    }
1822}