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 rayon::in_place_scope;
11use rayon::prelude::*;
12use rustc_hash::FxHashMap;
13use tokio::io::{AsyncRead, AsyncSeekExt, ReadBuf};
14use tokio::sync::Semaphore;
15use tokio_util::compat::FuturesAsyncReadCompatExt;
16use tracing::{Instrument, info_span, instrument, warn};
17use url::Url;
18
19use uv_cache::{ArchiveFileId, ArchiveId, Cache, CacheBucket, CacheEntry, WheelCache};
20use uv_cache_info::{CacheInfo, Timestamp};
21use uv_client::{
22    CacheControl, CachedClientError, Connectivity, DataWithCachePolicy, RegistryClient,
23};
24use uv_configuration::initialize_rayon_once;
25use uv_distribution_filename::WheelFilename;
26use uv_distribution_types::{
27    BuildInfo, BuildableSource, BuiltDist, Dist, DistRef, HashPolicy, Hashed, IndexUrl,
28    InstalledDist, Name, SourceDist,
29};
30use uv_extract::dirhash::{DirectoryDigest, HashedFile};
31use uv_extract::hash::Hasher;
32use uv_fs::{LockedFile, write_atomic};
33use uv_git::{GIT_LFS, GitError};
34use uv_platform_tags::Tags;
35use uv_preview::PreviewFeature;
36use uv_pypi_types::{HashDigest, HashDigests, PyProjectToml};
37use uv_python::PythonVariant;
38use uv_redacted::DisplaySafeUrl;
39use uv_types::{BuildContext, BuildStack};
40
41use crate::archive::Archive;
42use crate::error::PythonVersion;
43use crate::extracted_wheel::{ExtractedWheel, HashedWheel, WheelExtractor};
44use crate::hash::http_hash_algorithms;
45use crate::metadata::{ArchiveMetadata, Metadata};
46use crate::source::SourceDistributionBuilder;
47use crate::{Error, LocalWheel, Reporter, RequiresDist};
48
49/// A cached high-level interface to convert distributions (a requirement resolved to a location)
50/// to a wheel or wheel metadata.
51///
52/// For wheel metadata, this happens by either fetching the metadata from the remote wheel or by
53/// building the source distribution. For wheel files, either the wheel is downloaded or a source
54/// distribution is downloaded, built and the new wheel gets returned.
55///
56/// All kinds of wheel sources (index, URL, path) and source distribution source (index, URL, path,
57/// Git) are supported.
58///
59/// This struct also has the task of acquiring locks around source dist builds in general and git
60/// operation especially, as well as respecting concurrency limits.
61pub struct DistributionDatabase<'a, Context: BuildContext> {
62    build_context: &'a Context,
63    builder: SourceDistributionBuilder<'a, Context>,
64    client: ManagedClient<'a>,
65    reporter: Option<Arc<dyn Reporter>>,
66    content_addressed_cache: bool,
67}
68
69impl<'a, Context: BuildContext> DistributionDatabase<'a, Context> {
70    pub fn new(
71        client: &'a RegistryClient,
72        build_context: &'a Context,
73        downloads_semaphore: Arc<Semaphore>,
74    ) -> Self {
75        // When ZIP validation is disabled, the extracted tree can contain files that aren't
76        // represented in the central directory and therefore aren't included in its digest.
77        // Avoid using an incomplete digest as a content-addressed archive ID.
78        let content_addressed_cache = uv_preview::is_enabled(PreviewFeature::ContentAddressedCache)
79            && !uv_extract::insecure_no_validate();
80        Self {
81            build_context,
82            builder: SourceDistributionBuilder::new(build_context),
83            client: ManagedClient::new(client, downloads_semaphore),
84            reporter: None,
85            content_addressed_cache,
86        }
87    }
88
89    /// Set the build stack to use for the [`DistributionDatabase`].
90    #[must_use]
91    pub fn with_build_stack(self, build_stack: &'a BuildStack) -> Self {
92        Self {
93            builder: self.builder.with_build_stack(build_stack),
94            ..self
95        }
96    }
97
98    /// Set the [`Reporter`] to use for the [`DistributionDatabase`].
99    #[must_use]
100    pub fn with_reporter(self, reporter: Arc<dyn Reporter>) -> Self {
101        Self {
102            builder: self.builder.with_reporter(reporter.clone()),
103            reporter: Some(reporter),
104            ..self
105        }
106    }
107
108    /// Handle a specific `reqwest` error, and convert it to [`io::Error`].
109    fn handle_response_errors(&self, err: reqwest::Error) -> io::Error {
110        if err.is_timeout() {
111            // Assumption: The connect timeout with the 10s default is not the culprit.
112            io::Error::new(
113                io::ErrorKind::TimedOut,
114                format!(
115                    "Failed to download distribution due to network timeout. Try increasing UV_HTTP_TIMEOUT (current value: {}s).",
116                    self.client.unmanaged.read_timeout().as_secs()
117                ),
118            )
119        } else {
120            io::Error::other(err)
121        }
122    }
123
124    /// Acquire an advisory lock for a wheel cache entry.
125    ///
126    /// A remote wheel's content hash is not always available until after the download, so
127    /// concurrent cache fills coordinate on the wheel cache entry instead. The entry is already
128    /// scoped to the distribution's source and wheel filename.
129    ///
130    /// Callers hold the returned lock across cache lookup, download or extraction, and publication.
131    /// A process that waited for another cache fill therefore rechecks and reuses the completed entry.
132    async fn lock_wheel(
133        wheel_entry: &CacheEntry,
134        filename: &WheelFilename,
135    ) -> Result<LockedFile, Error> {
136        // For backwards compatibility, we use the full wheel stem on Windows. Local wheel
137        // extraction and older uv versions use the same key, so changing it would prevent them
138        // from coordinating through a shared cache.
139        #[cfg(windows)]
140        let lock_key = filename.stem();
141        // On other platforms, we use the bounded cache key to avoid filesystem filename limits.
142        #[cfg(not(windows))]
143        let lock_key = filename.cache_key();
144
145        let lock_entry = wheel_entry.with_file(format!("{lock_key}.lock"));
146        lock_entry.lock().await.map_err(Error::CacheLock)
147    }
148
149    /// Either fetch the wheel or fetch and build the source distribution
150    ///
151    /// Returns a wheel that's compliant with the given platform tags.
152    ///
153    /// While hashes will be generated in some cases, hash-checking is only enforced for source
154    /// distributions, and should be enforced by the caller for wheels.
155    #[instrument(skip_all, fields(%dist))]
156    pub async fn get_or_build_wheel(
157        &self,
158        dist: &Dist,
159        tags: &Tags,
160        hashes: HashPolicy<'_>,
161    ) -> Result<LocalWheel, Error> {
162        match dist {
163            Dist::Built(built) => self.get_wheel(built, hashes).await,
164            Dist::Source(source) => self.build_wheel(source, tags, hashes).await,
165        }
166    }
167
168    /// Either fetch the only wheel metadata (directly from the index or with range requests) or
169    /// fetch and build the source distribution.
170    ///
171    /// While hashes will be generated in some cases, hash-checking is only enforced for source
172    /// distributions, and should be enforced by the caller for wheels.
173    #[instrument(skip_all, fields(%dist))]
174    pub async fn get_installed_metadata(
175        &self,
176        dist: &InstalledDist,
177    ) -> Result<ArchiveMetadata, Error> {
178        // If the metadata was provided by the user directly, prefer it.
179        if let Some(metadata) = self
180            .build_context
181            .dependency_metadata()
182            .get(dist.name(), Some(dist.version()))
183        {
184            return Ok(ArchiveMetadata::from_metadata23(metadata));
185        }
186
187        let metadata = dist
188            .read_metadata()
189            .map_err(|err| Error::ReadInstalled(Box::new(dist.clone()), err))?;
190
191        Ok(ArchiveMetadata::from_metadata23(metadata.clone()))
192    }
193
194    /// Either fetch the only wheel metadata (directly from the index or with range requests) or
195    /// fetch and build the source distribution.
196    ///
197    /// While hashes will be generated in some cases, hash-checking is only enforced for source
198    /// distributions, and should be enforced by the caller for wheels.
199    #[instrument(skip_all, fields(%dist))]
200    pub async fn get_or_build_wheel_metadata(
201        &self,
202        dist: &Dist,
203        hashes: HashPolicy<'_>,
204    ) -> Result<ArchiveMetadata, Error> {
205        match dist {
206            Dist::Built(built) => self.get_wheel_metadata(built, hashes).await,
207            Dist::Source(source) => {
208                self.build_wheel_metadata(&BuildableSource::Dist(source), hashes)
209                    .await
210            }
211        }
212    }
213
214    /// Fetch a wheel from the cache or download it from the index.
215    ///
216    /// While hashes will be generated in all cases, hash-checking is _not_ enforced and should
217    /// instead be enforced by the caller.
218    async fn get_wheel(
219        &self,
220        dist: &BuiltDist,
221        hashes: HashPolicy<'_>,
222    ) -> Result<LocalWheel, Error> {
223        match dist {
224            BuiltDist::Registry(wheels) => {
225                let wheel = wheels.best_wheel();
226                let url = wheel.file.url.to_url()?;
227                let size = wheel.file.size;
228
229                // Create a cache entry for the wheel.
230                let wheel_entry = self.build_context.cache().entry(
231                    CacheBucket::Wheels,
232                    WheelCache::Index(&wheel.index).wheel_dir(wheel.name().as_ref()),
233                    wheel.filename.cache_key(),
234                );
235
236                // If the URL is a file URL, load the wheel directly.
237                if url.scheme() == "file" {
238                    let path = url
239                        .to_file_path()
240                        .map_err(|()| Error::NonFileUrl(url.clone()))?;
241                    return self
242                        .load_wheel(&path, &wheel.filename, wheel_entry, dist, hashes)
243                        .await;
244                }
245
246                // Download and unzip.
247                match self
248                    .stream_wheel(
249                        url.clone(),
250                        dist.index(),
251                        &wheel.filename,
252                        size,
253                        &wheel_entry,
254                        dist,
255                        hashes,
256                    )
257                    .await
258                {
259                    Ok(archive) => Ok(LocalWheel {
260                        dist: Dist::Built(dist.clone()),
261                        archive: self
262                            .build_context
263                            .cache()
264                            .archive(&archive.id)
265                            .into_boxed_path(),
266                        hashes: archive.hashes,
267                        filename: wheel.filename.clone(),
268                        cache: CacheInfo::default(),
269                        build: None,
270                    }),
271                    Err(Error::Extract(name, err)) => {
272                        if err.is_http_streaming_unsupported() {
273                            warn!(
274                                "Streaming unsupported for {dist}; downloading wheel to disk ({err})"
275                            );
276                        } else if err.is_http_streaming_failed() {
277                            warn!("Streaming failed for {dist}; downloading wheel to disk ({err})");
278                        } else {
279                            return Err(Error::Extract(name, err));
280                        }
281
282                        // If the request failed because streaming was unsupported or failed,
283                        // download the wheel directly.
284                        let archive = self
285                            .download_wheel(
286                                url,
287                                dist.index(),
288                                &wheel.filename,
289                                size,
290                                &wheel_entry,
291                                dist,
292                                hashes,
293                            )
294                            .await?;
295
296                        Ok(LocalWheel {
297                            dist: Dist::Built(dist.clone()),
298                            archive: self
299                                .build_context
300                                .cache()
301                                .archive(&archive.id)
302                                .into_boxed_path(),
303                            hashes: archive.hashes,
304                            filename: wheel.filename.clone(),
305                            cache: CacheInfo::default(),
306                            build: None,
307                        })
308                    }
309                    Err(err) => Err(err),
310                }
311            }
312
313            BuiltDist::DirectUrl(wheel) => {
314                // Create a cache entry for the wheel.
315                let wheel_entry = self.build_context.cache().entry(
316                    CacheBucket::Wheels,
317                    WheelCache::Url(&wheel.url).wheel_dir(wheel.name().as_ref()),
318                    wheel.filename.cache_key(),
319                );
320
321                // Download and unzip.
322                match self
323                    .stream_wheel(
324                        wheel.url.raw().clone(),
325                        None,
326                        &wheel.filename,
327                        wheel.size,
328                        &wheel_entry,
329                        dist,
330                        hashes,
331                    )
332                    .await
333                {
334                    Ok(archive) => Ok(LocalWheel {
335                        dist: Dist::Built(dist.clone()),
336                        archive: self
337                            .build_context
338                            .cache()
339                            .archive(&archive.id)
340                            .into_boxed_path(),
341                        hashes: archive.hashes,
342                        filename: wheel.filename.clone(),
343                        cache: CacheInfo::default(),
344                        build: None,
345                    }),
346                    Err(Error::Extract(name, err)) => {
347                        if err.is_http_streaming_unsupported() {
348                            warn!(
349                                "Streaming unsupported for {dist}; downloading wheel to disk ({err})"
350                            );
351                        } else if err.is_http_streaming_failed() {
352                            warn!("Streaming failed for {dist}; downloading wheel to disk ({err})");
353                        } else {
354                            return Err(Error::Extract(name, err));
355                        }
356
357                        // If the request failed because streaming was unsupported or failed,
358                        // download the wheel directly.
359                        let archive = self
360                            .download_wheel(
361                                wheel.url.raw().clone(),
362                                None,
363                                &wheel.filename,
364                                wheel.size,
365                                &wheel_entry,
366                                dist,
367                                hashes,
368                            )
369                            .await?;
370                        Ok(LocalWheel {
371                            dist: Dist::Built(dist.clone()),
372                            archive: self
373                                .build_context
374                                .cache()
375                                .archive(&archive.id)
376                                .into_boxed_path(),
377                            hashes: archive.hashes,
378                            filename: wheel.filename.clone(),
379                            cache: CacheInfo::default(),
380                            build: None,
381                        })
382                    }
383                    Err(err) => Err(err),
384                }
385            }
386
387            BuiltDist::GitPath(wheel) => {
388                // Fetch the Git repository.
389                let fetch = self
390                    .build_context
391                    .git()
392                    .fetch(
393                        &wheel.git,
394                        self.client.unmanaged.git_http_settings(wheel.git.url()),
395                        self.build_context.cache().bucket(CacheBucket::Git),
396                        self.reporter.clone().map(<dyn Reporter>::into_git_reporter),
397                    )
398                    .await?;
399
400                if wheel.git.lfs().enabled() && !fetch.lfs_ready() {
401                    if GIT_LFS.is_err() {
402                        return Err(Error::MissingWheelGitLfsArtifacts(
403                            wheel.url.to_url(),
404                            GitError::GitLfsNotFound,
405                        ));
406                    }
407                    return Err(Error::MissingWheelGitLfsArtifacts(
408                        wheel.url.to_url(),
409                        GitError::GitLfsNotConfigured,
410                    ));
411                }
412
413                let git_sha = fetch.git().precise().expect("Exact commit after checkout");
414                let cache_entry = self.build_context.cache().entry(
415                    CacheBucket::Wheels,
416                    WheelCache::Git(&wheel.url, git_sha.as_short_str()).root(),
417                    wheel.filename.stem(),
418                );
419
420                let install_path = fetch.path().join(&wheel.install_path);
421
422                self.load_wheel(&install_path, &wheel.filename, cache_entry, dist, hashes)
423                    .await
424            }
425
426            BuiltDist::Path(wheel) => {
427                let cache_entry = self.build_context.cache().entry(
428                    CacheBucket::Wheels,
429                    WheelCache::Url(&wheel.url).wheel_dir(wheel.name().as_ref()),
430                    wheel.filename.cache_key(),
431                );
432
433                self.load_wheel(
434                    &wheel.install_path,
435                    &wheel.filename,
436                    cache_entry,
437                    dist,
438                    hashes,
439                )
440                .await
441            }
442        }
443    }
444
445    /// Convert a source distribution into a wheel, fetching it from the cache or building it if
446    /// necessary.
447    ///
448    /// The returned wheel is guaranteed to come from a distribution with a matching hash, and
449    /// no build processes will be executed for distributions with mismatched hashes.
450    async fn build_wheel(
451        &self,
452        dist: &SourceDist,
453        tags: &Tags,
454        hashes: HashPolicy<'_>,
455    ) -> Result<LocalWheel, Error> {
456        let built_wheel = self
457            .builder
458            .download_and_build(&BuildableSource::Dist(dist), tags, hashes, &self.client)
459            .boxed_local()
460            .await?;
461
462        // Check that the wheel is compatible with its install target.
463        //
464        // When building a build dependency for a cross-install, the build dependency needs
465        // to install and run on the host instead of the target. In this case the `tags` are already
466        // for the host instead of the target, so this check passes.
467        if !built_wheel.filename.is_compatible(tags) {
468            return if tags.is_cross() {
469                Err(Error::BuiltWheelIncompatibleTargetPlatform {
470                    filename: built_wheel.filename,
471                    python_platform: tags.python_platform().clone(),
472                    python_version: PythonVersion {
473                        version: tags.python_version(),
474                        variant: if tags.is_freethreaded() {
475                            PythonVariant::Freethreaded
476                        } else {
477                            PythonVariant::Default
478                        },
479                    },
480                })
481            } else {
482                Err(Error::BuiltWheelIncompatibleHostPlatform {
483                    filename: built_wheel.filename,
484                    python_platform: tags.python_platform().clone(),
485                    python_version: PythonVersion {
486                        version: tags.python_version(),
487                        variant: if tags.is_freethreaded() {
488                            PythonVariant::Freethreaded
489                        } else {
490                            PythonVariant::Default
491                        },
492                    },
493                })
494            };
495        }
496
497        // Acquire the advisory lock.
498        let wheel_entry = CacheEntry::from_path(built_wheel.target.as_ref());
499        let _lock = Self::lock_wheel(&wheel_entry, &built_wheel.filename).await?;
500
501        // If the wheel was unzipped previously, respect it. Source distributions are
502        // cached under a unique revision ID, so unzipped directories are never stale.
503        match self.build_context.cache().resolve_link(&built_wheel.target) {
504            Ok(archive) => {
505                return Ok(LocalWheel {
506                    dist: Dist::Source(dist.clone()),
507                    archive: archive.into_boxed_path(),
508                    filename: built_wheel.filename,
509                    hashes: built_wheel.hashes,
510                    cache: built_wheel.cache_info,
511                    build: Some(built_wheel.build_info),
512                });
513            }
514            Err(err) if err.kind() == io::ErrorKind::NotFound => {}
515            Err(err) => return Err(Error::CacheRead(err)),
516        }
517
518        // Otherwise, unzip the wheel.
519        let id = self
520            .unzip_wheel(
521                &built_wheel.path,
522                &built_wheel.target,
523                DistRef::Source(dist),
524            )
525            .await?;
526
527        Ok(LocalWheel {
528            dist: Dist::Source(dist.clone()),
529            archive: self.build_context.cache().archive(&id).into_boxed_path(),
530            hashes: built_wheel.hashes,
531            filename: built_wheel.filename,
532            cache: built_wheel.cache_info,
533            build: Some(built_wheel.build_info),
534        })
535    }
536
537    /// Fetch the wheel metadata from the index, or from the cache if possible.
538    ///
539    /// While hashes will be generated in some cases, hash-checking is _not_ enforced and should
540    /// instead be enforced by the caller.
541    async fn get_wheel_metadata(
542        &self,
543        dist: &BuiltDist,
544        hashes: HashPolicy<'_>,
545    ) -> Result<ArchiveMetadata, Error> {
546        // If hash generation is enabled, and the distribution isn't hosted on a registry, get the
547        // entire wheel to ensure that the hashes are included in the response. If the distribution
548        // is hosted on an index, the hashes will be included in the simple metadata response.
549        // For hash _validation_, callers are expected to enforce the policy when retrieving the
550        // wheel.
551        //
552        // Historically, for `uv pip compile --universal`, we also generate hashes for
553        // registry-based distributions when the relevant registry doesn't provide them. This was
554        // motivated by `--find-links`. We continue that behavior (under `HashGeneration::All`) for
555        // backwards compatibility, but it's a little dubious, since we're only hashing _one_
556        // distribution here (as opposed to hashing all distributions for the version), and it may
557        // not even be a compatible distribution!
558        //
559        // TODO(charlie): Request the hashes via a separate method, to reduce the coupling in this API.
560        if hashes.is_generate(dist) {
561            let wheel = self.get_wheel(dist, hashes).await?;
562            // If the metadata was provided by the user directly, prefer it.
563            let metadata = if let Some(metadata) = self
564                .build_context
565                .dependency_metadata()
566                .get(dist.name(), Some(dist.version()))
567            {
568                metadata
569            } else {
570                wheel.metadata()?
571            };
572            let hashes = wheel.hashes;
573            return Ok(ArchiveMetadata {
574                metadata: Metadata::from_metadata23(metadata),
575                hashes,
576            });
577        }
578
579        // If the metadata was provided by the user directly, prefer it.
580        if let Some(metadata) = self
581            .build_context
582            .dependency_metadata()
583            .get(dist.name(), Some(dist.version()))
584        {
585            return Ok(ArchiveMetadata::from_metadata23(metadata));
586        }
587
588        let result = self
589            .client
590            .managed(|client| {
591                client
592                    .wheel_metadata(
593                        dist,
594                        self.build_context.git(),
595                        self.build_context.capabilities(),
596                        self.reporter.clone().map(<dyn Reporter>::into_git_reporter),
597                    )
598                    .boxed_local()
599            })
600            .await;
601
602        match result {
603            Ok(metadata) => {
604                // Validate that the metadata is consistent with the distribution.
605                Ok(ArchiveMetadata::from_metadata23(metadata))
606            }
607            Err(err) if err.is_http_streaming_unsupported() => {
608                warn!(
609                    "Streaming unsupported when fetching metadata for {dist}; downloading wheel directly ({err})"
610                );
611
612                // If the request failed due to an error that could be resolved by
613                // downloading the wheel directly, try that.
614                let wheel = self.get_wheel(dist, hashes).await?;
615                let metadata = wheel.metadata()?;
616                let hashes = wheel.hashes;
617                Ok(ArchiveMetadata {
618                    metadata: Metadata::from_metadata23(metadata),
619                    hashes,
620                })
621            }
622            Err(err) => Err(err.into()),
623        }
624    }
625
626    /// Build the wheel metadata for a source distribution, or fetch it from the cache if possible.
627    ///
628    /// The returned metadata is guaranteed to come from a distribution with a matching hash, and
629    /// no build processes will be executed for distributions with mismatched hashes.
630    pub async fn build_wheel_metadata(
631        &self,
632        source: &BuildableSource<'_>,
633        hashes: HashPolicy<'_>,
634    ) -> Result<ArchiveMetadata, Error> {
635        // If the metadata was provided by the user directly, prefer it.
636        if let Some(dist) = source.as_dist() {
637            if let Some(metadata) = self
638                .build_context
639                .dependency_metadata()
640                .get(dist.name(), dist.version())
641            {
642                // If we skipped the build, we should still resolve any Git dependencies to precise
643                // commits.
644                self.builder.resolve_revision(source, &self.client).await?;
645
646                return Ok(ArchiveMetadata::from_metadata23(metadata));
647            }
648        }
649
650        let metadata = self
651            .builder
652            .download_and_build_metadata(source, hashes, &self.client)
653            .boxed_local()
654            .await?;
655
656        Ok(metadata)
657    }
658
659    /// Return the [`RequiresDist`] from a `pyproject.toml`, if it can be statically extracted.
660    pub async fn requires_dist(
661        &self,
662        path: &Path,
663        pyproject_toml: &PyProjectToml,
664    ) -> Result<Option<RequiresDist>, Error> {
665        self.builder
666            .source_tree_requires_dist(
667                path,
668                pyproject_toml,
669                self.client.unmanaged.credentials_cache(),
670            )
671            .await
672    }
673
674    /// Stream a wheel from a URL, unzipping it into the cache as it's downloaded.
675    async fn stream_wheel(
676        &self,
677        url: DisplaySafeUrl,
678        index: Option<&IndexUrl>,
679        filename: &WheelFilename,
680        size: Option<u64>,
681        wheel_entry: &CacheEntry,
682        dist: &BuiltDist,
683        hashes: HashPolicy<'_>,
684    ) -> Result<Archive, Error> {
685        let expected_size = match dist {
686            BuiltDist::Registry(dist) if dist.best_wheel().size_is_authoritative => size,
687            BuiltDist::DirectUrl(_) => size,
688            _ => None,
689        };
690
691        // Acquire an advisory lock, to guard against concurrent writes.
692        let _lock = Self::lock_wheel(wheel_entry, filename).await?;
693
694        // Create an entry for the HTTP cache.
695        let http_entry = wheel_entry.with_file(format!("{}.http", filename.cache_key()));
696
697        let download = |response: reqwest::Response| {
698            async {
699                let progress_size = size.or_else(|| content_length(&response));
700
701                let progress = self.reporter.as_ref().map(|reporter| {
702                    (
703                        reporter,
704                        reporter.on_download_start(dist.name(), progress_size),
705                    )
706                });
707
708                let reader = response
709                    .bytes_stream()
710                    .map_err(|err| self.handle_response_errors(err))
711                    .into_async_read();
712
713                // Create a hasher for each hash algorithm.
714                let algorithms = http_hash_algorithms(hashes);
715                let mut hashers = algorithms.into_iter().map(Hasher::from).collect::<Vec<_>>();
716                let mut hasher = uv_extract::hash::HashReader::new(reader.compat(), &mut hashers);
717
718                // Download and unzip the wheel to a temporary directory.
719                let extractor = WheelExtractor::new(
720                    self.build_context.cache().root(),
721                    self.content_addressed_cache,
722                )
723                .map_err(Error::CacheWrite)?;
724
725                let mut extracted = match progress {
726                    Some((reporter, progress)) => {
727                        let mut reader = ProgressReader::new(&mut hasher, progress, &**reporter);
728                        extractor
729                            .extract_streaming(&mut reader)
730                            .await
731                            .map_err(|err| Error::Extract(filename.to_string(), err))?
732                    }
733                    None => extractor
734                        .extract_streaming(&mut hasher)
735                        .await
736                        .map_err(|err| Error::Extract(filename.to_string(), err))?,
737                };
738                // Exhaust the reader to compute the hashes.
739                hasher.finish().await.map_err(Error::HashExhaustion)?;
740                let actual_size = hasher.bytes_read();
741                if let Some(expected) = expected_size
742                    && actual_size != expected
743                {
744                    return Err(Error::MismatchedSize {
745                        distribution: dist.to_string(),
746                        expected,
747                        actual: actual_size,
748                    });
749                }
750
751                // Before we make the wheel accessible by persisting it, ensure that the RECORD is
752                // valid.
753                extracted.validate_and_heal_record(dist)?;
754
755                // Persist the temporary directory to the directory store.
756                let id = self
757                    .persist_extracted_wheel(extracted, wheel_entry.path())
758                    .await?;
759
760                if let Some((reporter, progress)) = progress {
761                    reporter.on_download_complete(dist.name(), progress);
762                }
763
764                Ok(Archive::new(
765                    id,
766                    hashers.into_iter().map(HashDigest::from).collect(),
767                    filename.clone(),
768                    Some(actual_size),
769                ))
770            }
771            .instrument(info_span!("wheel", wheel = %dist))
772        };
773
774        // Fetch the archive from the cache, or download it if necessary.
775        let req = self.request(url.clone())?;
776
777        // Determine the cache control policy for the URL.
778        let cache_control = match self.client.unmanaged.connectivity() {
779            Connectivity::Online
780                if let Some(header) = index.and_then(|index| {
781                    self.build_context
782                        .locations()
783                        .artifact_cache_control_for(index)
784                }) =>
785            {
786                CacheControl::Override(header)
787            }
788            Connectivity::Online => CacheControl::from(
789                self.build_context
790                    .cache()
791                    .freshness(&http_entry, Some(&filename.name), None)
792                    .map_err(Error::CacheRead)?,
793            ),
794            Connectivity::Offline => CacheControl::AllowStale,
795        };
796
797        let archive = self
798            .client
799            .managed(|client| {
800                client.cached_client().get_serde_with_retry(
801                    req,
802                    &http_entry,
803                    cache_control.clone(),
804                    download,
805                )
806            })
807            .await
808            .map_err(|err| match err {
809                CachedClientError::Callback { err, .. } => err,
810                CachedClientError::Client(err) => Error::Client(err),
811            })?;
812
813        if let (Some(expected), Some(actual)) = (expected_size, archive.size)
814            && expected != actual
815        {
816            return Err(Error::MismatchedSize {
817                distribution: dist.to_string(),
818                expected,
819                actual,
820            });
821        }
822
823        // If the archive is missing the required hashes or size, or has since been removed, force a refresh.
824        let archive = Some(archive)
825            .filter(|archive| archive.has_digests(hashes))
826            .filter(|archive| archive.exists(self.build_context.cache()))
827            .filter(|archive| expected_size.is_none() || archive.size.is_some());
828
829        let archive = if let Some(archive) = archive {
830            archive
831        } else {
832            self.client
833                .managed(async |client| {
834                    client
835                        .cached_client()
836                        .skip_cache_with_retry(
837                            self.request(url)?,
838                            &http_entry,
839                            cache_control,
840                            download,
841                        )
842                        .await
843                        .map_err(|err| match err {
844                            CachedClientError::Callback { err, .. } => err,
845                            CachedClientError::Client(err) => Error::Client(err),
846                        })
847                })
848                .await?
849        };
850
851        Ok(archive)
852    }
853
854    /// Download a wheel from a URL, then unzip it into the cache.
855    async fn download_wheel(
856        &self,
857        url: DisplaySafeUrl,
858        index: Option<&IndexUrl>,
859        filename: &WheelFilename,
860        size: Option<u64>,
861        wheel_entry: &CacheEntry,
862        dist: &BuiltDist,
863        hashes: HashPolicy<'_>,
864    ) -> Result<Archive, Error> {
865        let expected_size = match dist {
866            BuiltDist::Registry(dist) if dist.best_wheel().size_is_authoritative => size,
867            BuiltDist::DirectUrl(_) => size,
868            _ => None,
869        };
870
871        let content_addressed_cache = self.content_addressed_cache;
872
873        // Acquire an advisory lock, to guard against concurrent writes.
874        let _lock = Self::lock_wheel(wheel_entry, filename).await?;
875
876        // Create an entry for the HTTP cache.
877        let http_entry = wheel_entry.with_file(format!("{}.http", filename.cache_key()));
878
879        let download = |response: reqwest::Response| {
880            async {
881                let progress_size = size.or_else(|| content_length(&response));
882
883                let progress = self.reporter.as_ref().map(|reporter| {
884                    (
885                        reporter,
886                        reporter.on_download_start(dist.name(), progress_size),
887                    )
888                });
889
890                let reader = response
891                    .bytes_stream()
892                    .map_err(|err| self.handle_response_errors(err))
893                    .into_async_read();
894                let algorithms = http_hash_algorithms(hashes);
895                let mut hashers = algorithms.into_iter().map(Hasher::from).collect::<Vec<_>>();
896                let mut hasher = uv_extract::hash::HashReader::new(reader.compat(), &mut hashers);
897
898                // Download the wheel to a temporary file.
899                let temp_file = tempfile::tempfile_in(self.build_context.cache().root())
900                    .map_err(Error::CacheWrite)?;
901                let mut writer = tokio::io::BufWriter::new(fs_err::tokio::File::from_std(
902                    // It's an unnamed file on Linux so that's the best approximation.
903                    fs_err::File::from_parts(temp_file, self.build_context.cache().root()),
904                ));
905
906                match progress {
907                    Some((reporter, progress)) => {
908                        // Wrap the reader in a progress reporter. This will report 100% progress once
909                        // the download is complete, before the wheel is unzipped.
910                        let mut reader = ProgressReader::new(&mut hasher, progress, &**reporter);
911
912                        tokio::io::copy(&mut reader, &mut writer)
913                            .await
914                            .map_err(Error::CacheWrite)?;
915                    }
916                    None => {
917                        tokio::io::copy(&mut hasher, &mut writer)
918                            .await
919                            .map_err(Error::CacheWrite)?;
920                    }
921                }
922
923                if let Some(expected) = expected_size
924                    && hasher.bytes_read() != expected
925                {
926                    return Err(Error::MismatchedSize {
927                        distribution: dist.to_string(),
928                        expected,
929                        actual: hasher.bytes_read(),
930                    });
931                }
932
933                let actual_size = hasher.bytes_read();
934
935                // Unzip the wheel to a temporary directory.
936                let extractor =
937                    WheelExtractor::new(self.build_context.cache().root(), content_addressed_cache)
938                        .map_err(Error::CacheWrite)?;
939                let mut file = writer.into_inner();
940                file.seek(io::SeekFrom::Start(0))
941                    .await
942                    .map_err(Error::CacheWrite)?;
943
944                let file = file.into_std().await;
945                let mut extracted =
946                    tokio::task::spawn_blocking(move || extractor.extract_seekable(file))
947                        .await?
948                        .map_err(|err| Error::Extract(filename.to_string(), err))?;
949                let hashes = hashers.into_iter().map(HashDigest::from).collect();
950
951                // Before we make the wheel accessible by persisting it, ensure that the RECORD is
952                // valid.
953                extracted.validate_and_heal_record(dist)?;
954
955                // Persist the temporary directory to the directory store.
956                let id = self
957                    .persist_extracted_wheel(extracted, wheel_entry.path())
958                    .await?;
959
960                if let Some((reporter, progress)) = progress {
961                    reporter.on_download_complete(dist.name(), progress);
962                }
963
964                Ok(Archive::new(
965                    id,
966                    hashes,
967                    filename.clone(),
968                    Some(actual_size),
969                ))
970            }
971            .instrument(info_span!("wheel", wheel = %dist))
972        };
973
974        // Fetch the archive from the cache, or download it if necessary.
975        let req = self.request(url.clone())?;
976
977        // Determine the cache control policy for the URL.
978        let cache_control = match self.client.unmanaged.connectivity() {
979            Connectivity::Online
980                if let Some(header) = index.and_then(|index| {
981                    self.build_context
982                        .locations()
983                        .artifact_cache_control_for(index)
984                }) =>
985            {
986                CacheControl::Override(header)
987            }
988            Connectivity::Online => CacheControl::from(
989                self.build_context
990                    .cache()
991                    .freshness(&http_entry, Some(&filename.name), None)
992                    .map_err(Error::CacheRead)?,
993            ),
994            Connectivity::Offline => CacheControl::AllowStale,
995        };
996
997        let archive = self
998            .client
999            .managed(|client| {
1000                client.cached_client().get_serde_with_retry(
1001                    req,
1002                    &http_entry,
1003                    cache_control.clone(),
1004                    download,
1005                )
1006            })
1007            .await
1008            .map_err(|err| match err {
1009                CachedClientError::Callback { err, .. } => err,
1010                CachedClientError::Client(err) => Error::Client(err),
1011            })?;
1012
1013        if let (Some(expected), Some(actual)) = (expected_size, archive.size)
1014            && expected != actual
1015        {
1016            return Err(Error::MismatchedSize {
1017                distribution: dist.to_string(),
1018                expected,
1019                actual,
1020            });
1021        }
1022
1023        // If the archive is missing the required hashes or size, or has since been removed, force a refresh.
1024        let archive = Some(archive)
1025            .filter(|archive| archive.has_digests(hashes))
1026            .filter(|archive| archive.exists(self.build_context.cache()))
1027            .filter(|archive| expected_size.is_none() || archive.size.is_some());
1028
1029        let archive = if let Some(archive) = archive {
1030            archive
1031        } else {
1032            self.client
1033                .managed(async |client| {
1034                    client
1035                        .cached_client()
1036                        .skip_cache_with_retry(
1037                            self.request(url)?,
1038                            &http_entry,
1039                            cache_control,
1040                            download,
1041                        )
1042                        .await
1043                        .map_err(|err| match err {
1044                            CachedClientError::Callback { err, .. } => err,
1045                            CachedClientError::Client(err) => Error::Client(err),
1046                        })
1047                })
1048                .await?
1049        };
1050
1051        Ok(archive)
1052    }
1053
1054    /// Load a wheel from a local path.
1055    async fn load_wheel(
1056        &self,
1057        path: &Path,
1058        filename: &WheelFilename,
1059        wheel_entry: CacheEntry,
1060        dist: &BuiltDist,
1061        hashes: HashPolicy<'_>,
1062    ) -> Result<LocalWheel, Error> {
1063        // Acquire an advisory lock, to guard against concurrent writes.
1064        let _lock = Self::lock_wheel(&wheel_entry, filename).await?;
1065
1066        // Determine the last-modified time of the wheel.
1067        let modified = Timestamp::from_path(path).map_err(Error::CacheRead)?;
1068
1069        // Attempt to read the archive pointer from the cache.
1070        let pointer_entry = wheel_entry.with_file(format!("{}.rev", filename.cache_key()));
1071        let pointer = PathArchivePointer::read_from(&pointer_entry)?;
1072
1073        // Extract the archive from the pointer.
1074        let archive = pointer
1075            .filter(|pointer| pointer.is_up_to_date(modified))
1076            .map(PathArchivePointer::into_archive)
1077            .filter(|archive| archive.has_digests(hashes));
1078
1079        // If the file is already unzipped, and the cache is up-to-date, return it.
1080        if let Some(archive) = archive {
1081            Ok(LocalWheel {
1082                dist: Dist::Built(dist.clone()),
1083                archive: self
1084                    .build_context
1085                    .cache()
1086                    .archive(&archive.id)
1087                    .into_boxed_path(),
1088                hashes: archive.hashes,
1089                filename: filename.clone(),
1090                cache: CacheInfo::from_timestamp(modified),
1091                build: None,
1092            })
1093        } else if hashes.is_none() {
1094            // Otherwise, unzip the wheel.
1095            let archive = Archive::new(
1096                self.unzip_wheel(path, wheel_entry.path(), DistRef::Built(dist))
1097                    .await?,
1098                HashDigests::empty(),
1099                filename.clone(),
1100                None,
1101            );
1102
1103            // Write the archive pointer to the cache.
1104            let pointer = PathArchivePointer {
1105                timestamp: modified,
1106                archive: archive.clone(),
1107            };
1108            pointer.write_to(&pointer_entry).await?;
1109
1110            Ok(LocalWheel {
1111                dist: Dist::Built(dist.clone()),
1112                archive: self
1113                    .build_context
1114                    .cache()
1115                    .archive(&archive.id)
1116                    .into_boxed_path(),
1117                hashes: archive.hashes,
1118                filename: filename.clone(),
1119                cache: CacheInfo::from_timestamp(modified),
1120                build: None,
1121            })
1122        } else {
1123            // If necessary, compute the hashes of the wheel.
1124            let file = fs_err::tokio::File::open(path)
1125                .await
1126                .map_err(Error::CacheRead)?;
1127            let extractor = WheelExtractor::new(
1128                self.build_context.cache().root(),
1129                self.content_addressed_cache,
1130            )
1131            .map_err(Error::CacheWrite)?;
1132
1133            // Create a hasher for each hash algorithm.
1134            let algorithms = hashes.algorithms();
1135            let mut hashers = algorithms.into_iter().map(Hasher::from).collect::<Vec<_>>();
1136            let mut hasher = uv_extract::hash::HashReader::new(file, &mut hashers);
1137
1138            // Unzip the wheel to a temporary directory.
1139            let mut extracted = extractor
1140                .extract_streaming(&mut hasher)
1141                .await
1142                .map_err(|err| Error::Extract(filename.to_string(), err))?;
1143
1144            // Exhaust the reader to compute the hash.
1145            hasher.finish().await.map_err(Error::HashExhaustion)?;
1146
1147            let hashes = hashers.into_iter().map(HashDigest::from).collect();
1148
1149            // Before we make the wheel accessible by persisting it, ensure that the RECORD is
1150            // valid.
1151            extracted.validate_and_heal_record(dist)?;
1152
1153            // Persist the temporary directory to the directory store.
1154            let id = self
1155                .persist_extracted_wheel(extracted, wheel_entry.path())
1156                .await?;
1157
1158            // Create an archive.
1159            let archive = Archive::new(id, hashes, filename.clone(), None);
1160
1161            // Write the archive pointer to the cache.
1162            let pointer = PathArchivePointer {
1163                timestamp: modified,
1164                archive: archive.clone(),
1165            };
1166            pointer.write_to(&pointer_entry).await?;
1167
1168            Ok(LocalWheel {
1169                dist: Dist::Built(dist.clone()),
1170                archive: self
1171                    .build_context
1172                    .cache()
1173                    .archive(&archive.id)
1174                    .into_boxed_path(),
1175                hashes: archive.hashes,
1176                filename: filename.clone(),
1177                cache: CacheInfo::from_timestamp(modified),
1178                build: None,
1179            })
1180        }
1181    }
1182
1183    /// Unzip a wheel into the cache, returning the path to the unzipped directory.
1184    async fn unzip_wheel(
1185        &self,
1186        path: &Path,
1187        target: &Path,
1188        dist: DistRef<'_>,
1189    ) -> Result<ArchiveId, Error> {
1190        let content_addressed_cache = self.content_addressed_cache;
1191
1192        let mut extracted = tokio::task::spawn_blocking({
1193            let path = path.to_owned();
1194            let root = self.build_context.cache().root().to_path_buf();
1195            move || -> Result<_, Error> {
1196                // Unzip the wheel into a temporary directory.
1197                let extractor = WheelExtractor::new(&root, content_addressed_cache)
1198                    .map_err(Error::CacheWrite)?;
1199                let reader = fs_err::File::open(&path).map_err(Error::CacheWrite)?;
1200                extractor
1201                    .extract_seekable(reader)
1202                    .map_err(|err| Error::Extract(path.to_string_lossy().into_owned(), err))
1203            }
1204        })
1205        .await??;
1206
1207        // Before we make the wheel accessible by persisting it, ensure that the RECORD is valid.
1208        extracted.validate_and_heal_record(dist)?;
1209
1210        // Persist the temporary directory to the directory store.
1211        let id = self.persist_extracted_wheel(extracted, target).await?;
1212
1213        Ok(id)
1214    }
1215
1216    /// Persist an extracted wheel into the archive store.
1217    ///
1218    /// A hash tree makes identical extracted trees converge on one archive entry. Without one,
1219    /// persistence retains the existing behavior of assigning a unique archive ID.
1220    async fn persist_extracted_wheel(
1221        &self,
1222        extracted: ExtractedWheel,
1223        target: &Path,
1224    ) -> Result<ArchiveId, Error> {
1225        let (temp_dir, hashed_wheel) = extracted.into_parts();
1226        let cache = self.build_context.cache();
1227        let (temp_dir, id) = if let Some(HashedWheel { files, tree }) = hashed_wheel {
1228            let digest = DirectoryDigest::from(tree.hash());
1229            let id = ArchiveId::from_digest(digest.into());
1230            let cache = cache.clone();
1231            let temp_dir = tokio::task::spawn_blocking(move || {
1232                persist_archive_files(&cache, temp_dir.path(), &files)
1233                    .map_err(Error::CacheWrite)?;
1234                Ok::<_, Error>(temp_dir)
1235            })
1236            .await??;
1237            (temp_dir, id)
1238        } else {
1239            (temp_dir, ArchiveId::default())
1240        };
1241
1242        cache
1243            .persist_with_id(temp_dir, target, id)
1244            .await
1245            .map_err(Error::CacheWrite)
1246    }
1247
1248    /// Returns a GET [`reqwest::Request`] for the given URL.
1249    fn request(&self, url: DisplaySafeUrl) -> Result<reqwest::Request, reqwest::Error> {
1250        self.client
1251            .unmanaged
1252            .uncached_client(&url)
1253            .get(Url::from(url))
1254            .header(
1255                // `reqwest` defaults to accepting compressed responses.
1256                // Specify identity encoding to get consistent .whl downloading
1257                // behavior from servers. ref: https://github.com/pypa/pip/pull/1688
1258                "accept-encoding",
1259                reqwest::header::HeaderValue::from_static("identity"),
1260            )
1261            .build()
1262    }
1263
1264    /// Return the [`ManagedClient`] used by this resolver.
1265    pub fn client(&self) -> &ManagedClient<'a> {
1266        &self.client
1267    }
1268}
1269
1270/// Share extracted files other than `RECORD` while keeping the unpublished archive complete.
1271fn persist_archive_files(cache: &Cache, archive: &Path, files: &[HashedFile]) -> io::Result<()> {
1272    initialize_rayon_once();
1273    let targets = files
1274        .par_iter()
1275        // Keep RECORD private, since it may have been healed after hashing.
1276        .filter(|file| !file.path().ends_with("RECORD"))
1277        .map(|file| {
1278            let id = ArchiveFileId::from_digest(&file.object_digest_hex());
1279            (archive.join(file.path()), cache.archive_file(&id))
1280        })
1281        .collect::<Vec<_>>();
1282
1283    // Group files by shard so its directory is created once and its files are linked by the
1284    // same worker, avoiding contention between workers on each shard directory.
1285    let mut shards: FxHashMap<&Path, Vec<_>> = FxHashMap::default();
1286    for (source, target) in &targets {
1287        let Some(parent) = target.parent() else {
1288            return Err(io::Error::new(
1289                io::ErrorKind::InvalidInput,
1290                "archive file path must have a parent directory",
1291            ));
1292        };
1293        shards.entry(parent).or_default().push((source, target));
1294    }
1295
1296    let mut shards = shards
1297        .into_iter()
1298        .map(|(parent, files)| (parent, files, Ok(())))
1299        .collect::<Vec<_>>();
1300    // Start larger shards first so their work can overlap the remaining directory creation.
1301    shards.sort_unstable_by_key(|(_, files, _)| Reverse(files.len()));
1302
1303    // Creating shards concurrently contends on their shared parent. Keep creation on this
1304    // thread, while workers link files in the shards that are already available.
1305    in_place_scope(|scope| -> io::Result<()> {
1306        for (parent, files, result) in &mut shards {
1307            fs_err::create_dir_all(parent)?;
1308            scope.spawn(move |_| {
1309                *result = files
1310                    .iter()
1311                    .try_for_each(|(source, target)| persist_archive_file(source, target));
1312            });
1313        }
1314        Ok(())
1315    })?;
1316
1317    shards.into_iter().try_for_each(|(_, _, result)| result)
1318}
1319
1320/// Publish a shared object and retain a hardlink in the archive, with a copy fallback.
1321fn persist_archive_file(src: &Path, dst: &Path) -> io::Result<()> {
1322    // The shard already exists, and most objects are new, so try linking before checking for an
1323    // existing object. This avoids an extra filesystem lookup for every new object.
1324    match fs_err::hard_link(src, dst) {
1325        Ok(()) => return Ok(()),
1326        Err(_) if dst.try_exists()? => {}
1327        Err(_) => return uv_fs::copy_atomic_sync(src, dst),
1328    }
1329
1330    // This archive is still private, so it is safe to replace its extracted copy before publication.
1331    if let Err(err) = fs_err::remove_file(src)
1332        && err.kind() != io::ErrorKind::NotFound
1333    {
1334        return Err(err);
1335    }
1336
1337    fs_err::hard_link(dst, src).or_else(|_| uv_fs::copy_atomic_sync(dst, src))
1338}
1339
1340/// A wrapper around `RegistryClient` that manages a concurrency limit.
1341pub struct ManagedClient<'a> {
1342    pub unmanaged: &'a RegistryClient,
1343    control: Arc<Semaphore>,
1344}
1345
1346impl<'a> ManagedClient<'a> {
1347    /// Create a new `ManagedClient` using the given client and concurrency semaphore.
1348    fn new(client: &'a RegistryClient, control: Arc<Semaphore>) -> Self {
1349        ManagedClient {
1350            unmanaged: client,
1351            control,
1352        }
1353    }
1354
1355    /// Perform a request using the client, respecting the concurrency limit.
1356    ///
1357    /// If the concurrency limit has been reached, this method will wait until a pending
1358    /// operation completes before executing the closure.
1359    pub async fn managed<F, T>(&self, f: impl FnOnce(&'a RegistryClient) -> F) -> T
1360    where
1361        F: Future<Output = T>,
1362    {
1363        let _permit = self.control.acquire().await.unwrap();
1364        f(self.unmanaged).await
1365    }
1366
1367    /// Perform a request using a client that internally manages the concurrency limit.
1368    ///
1369    /// The callback is passed the client and a semaphore. It must acquire the semaphore before
1370    /// any request through the client and drop it after.
1371    ///
1372    /// This method serves as an escape hatch for functions that may want to send multiple requests
1373    /// in parallel.
1374    pub async fn manual<F, T>(&'a self, f: impl FnOnce(&'a RegistryClient, &'a Semaphore) -> F) -> T
1375    where
1376        F: Future<Output = T>,
1377    {
1378        f(self.unmanaged, &self.control).await
1379    }
1380}
1381
1382/// Returns the value of the `Content-Length` header from the [`reqwest::Response`], if present.
1383fn content_length(response: &reqwest::Response) -> Option<u64> {
1384    response
1385        .headers()
1386        .get(reqwest::header::CONTENT_LENGTH)
1387        .and_then(|val| val.to_str().ok())
1388        .and_then(|val| val.parse::<u64>().ok())
1389}
1390
1391/// An asynchronous reader that reports progress as bytes are read.
1392struct ProgressReader<'a, R> {
1393    reader: R,
1394    index: usize,
1395    reporter: &'a dyn Reporter,
1396}
1397
1398impl<'a, R> ProgressReader<'a, R> {
1399    /// Create a new [`ProgressReader`] that wraps another reader.
1400    fn new(reader: R, index: usize, reporter: &'a dyn Reporter) -> Self {
1401        Self {
1402            reader,
1403            index,
1404            reporter,
1405        }
1406    }
1407}
1408
1409impl<R> AsyncRead for ProgressReader<'_, R>
1410where
1411    R: AsyncRead + Unpin,
1412{
1413    fn poll_read(
1414        mut self: Pin<&mut Self>,
1415        cx: &mut Context<'_>,
1416        buf: &mut ReadBuf<'_>,
1417    ) -> Poll<io::Result<()>> {
1418        Pin::new(&mut self.as_mut().reader)
1419            .poll_read(cx, buf)
1420            .map_ok(|()| {
1421                self.reporter
1422                    .on_download_progress(self.index, buf.filled().len() as u64);
1423            })
1424    }
1425}
1426
1427/// A pointer to an archive in the cache, fetched from an HTTP archive.
1428///
1429/// Encoded with `MsgPack`, and represented on disk by a `.http` file.
1430#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1431pub struct HttpArchivePointer {
1432    archive: Archive,
1433}
1434
1435impl HttpArchivePointer {
1436    /// Read an [`HttpArchivePointer`] from the cache.
1437    pub fn read_from(path: impl AsRef<Path>) -> Result<Option<Self>, Error> {
1438        match fs_err::File::open(path.as_ref()) {
1439            Ok(file) => {
1440                let data = DataWithCachePolicy::from_reader(file)?.data;
1441                let archive = rmp_serde::from_slice::<Archive>(&data)?;
1442                Ok(Some(Self { archive }))
1443            }
1444            Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
1445            Err(err) => Err(Error::CacheRead(err)),
1446        }
1447    }
1448
1449    /// Return the [`Archive`] from the pointer.
1450    pub fn into_archive(self) -> Archive {
1451        self.archive
1452    }
1453
1454    /// Return the [`CacheInfo`] from the pointer.
1455    pub fn to_cache_info(&self) -> CacheInfo {
1456        CacheInfo::default()
1457    }
1458
1459    /// Return the [`BuildInfo`] from the pointer.
1460    pub fn to_build_info(&self) -> Option<BuildInfo> {
1461        None
1462    }
1463}
1464
1465/// A pointer to an archive in the cache, fetched from a local path.
1466///
1467/// Encoded with `MsgPack`, and represented on disk by a `.rev` file.
1468#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1469pub struct PathArchivePointer {
1470    timestamp: Timestamp,
1471    archive: Archive,
1472}
1473
1474impl PathArchivePointer {
1475    /// Read an [`PathArchivePointer`] from the cache.
1476    pub fn read_from(path: impl AsRef<Path>) -> Result<Option<Self>, Error> {
1477        match fs_err::read(path) {
1478            Ok(cached) => Ok(Some(rmp_serde::from_slice::<Self>(&cached)?)),
1479            Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
1480            Err(err) => Err(Error::CacheRead(err)),
1481        }
1482    }
1483
1484    /// Write an [`PathArchivePointer`] to the cache.
1485    async fn write_to(&self, entry: &CacheEntry) -> Result<(), Error> {
1486        write_atomic(entry.path(), rmp_serde::to_vec(&self)?)
1487            .await
1488            .map_err(Error::CacheWrite)
1489    }
1490
1491    /// Returns `true` if the archive is up-to-date with the given modified timestamp.
1492    pub fn is_up_to_date(&self, modified: Timestamp) -> bool {
1493        self.timestamp == modified
1494    }
1495
1496    /// Return the [`Archive`] from the pointer.
1497    pub fn into_archive(self) -> Archive {
1498        self.archive
1499    }
1500
1501    /// Return the [`CacheInfo`] from the pointer.
1502    pub fn to_cache_info(&self) -> CacheInfo {
1503        CacheInfo::from_timestamp(self.timestamp)
1504    }
1505
1506    /// Return the [`BuildInfo`] from the pointer.
1507    pub fn to_build_info(&self) -> Option<BuildInfo> {
1508        None
1509    }
1510}