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