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