1use std::cmp::Reverse;
2use std::future::Future;
3use std::io;
4use std::path::Path;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::task::{Context, Poll};
8
9use futures::{FutureExt, TryStreamExt};
10use http_content_range::{ContentRange, ContentRangeBytes, ContentRangeUnbound};
11use rayon::in_place_scope;
12use rayon::prelude::*;
13use rustc_hash::FxHashMap;
14use tokio::io::{AsyncRead, AsyncSeekExt, AsyncWriteExt, ReadBuf};
15use tokio::sync::Semaphore;
16use tokio_util::compat::FuturesAsyncReadCompatExt;
17use tracing::{Instrument, debug, info_span, instrument, warn};
18use url::Url;
19
20use uv_cache::{ArchiveFileId, ArchiveId, Cache, CacheBucket, CacheEntry, WheelCache};
21use uv_cache_info::{CacheInfo, Timestamp};
22use uv_client::{
23 CacheControl, CachedClientError, Connectivity, DataWithCachePolicy, RegistryClient,
24 RequestBuilder, RetryState,
25};
26use uv_configuration::initialize_rayon_once;
27use uv_distribution_filename::WheelFilename;
28use uv_distribution_types::{
29 ArchiveHashPolicy, BuildInfo, BuildableSource, BuiltDist, Dist, DistRef, HashCollection,
30 HashValidation, Hashed, IndexUrl, InstalledDist, MetadataHashPolicy, Name, SourceDist,
31 SourceUrl, parse_url_hashes,
32};
33use uv_extract::dirhash::{DirectoryDigest, HashedFile};
34use uv_extract::hash::Hasher;
35use uv_fs::{LockedFile, write_atomic};
36use uv_git::{GIT_LFS, GitError};
37use uv_platform_tags::Tags;
38use uv_preview::PreviewFeature;
39use uv_pypi_types::{HashDigest, HashDigests, PyProjectToml};
40use uv_python::PythonVariant;
41use uv_redacted::DisplaySafeUrl;
42use uv_types::{BuildContext, BuildStack};
43
44use crate::archive::Archive;
45use crate::error::PythonVersion;
46use crate::extracted_wheel::{ExtractedWheel, HashedWheel, WheelExtractor};
47use crate::hash::http_hash_algorithms;
48use crate::metadata::{ArchiveMetadata, Metadata};
49use crate::source::SourceDistributionBuilder;
50use crate::{Error, LocalWheel, Reporter, RequiresDist};
51
52pub 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 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 #[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 #[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 fn handle_response_errors(&self, err: reqwest::Error) -> io::Error {
113 if err.is_timeout() {
114 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 async fn lock_wheel(
136 wheel_entry: &CacheEntry,
137 filename: &WheelFilename,
138 ) -> Result<LockedFile, Error> {
139 #[cfg(windows)]
143 let lock_key = filename.stem();
144 #[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 #[instrument(skip_all, fields(%dist))]
159 pub async fn get_or_build_wheel(
160 &self,
161 dist: &Dist,
162 tags: &Tags,
163 hashes: ArchiveHashPolicy<'_>,
164 ) -> Result<LocalWheel, Error> {
165 match dist {
166 Dist::Built(built) => self.get_wheel(built, hashes).await,
167 Dist::Source(source) => self.build_wheel(source, tags, hashes).await,
168 }
169 }
170
171 #[instrument(skip_all, fields(%dist))]
177 pub async fn get_installed_metadata(
178 &self,
179 dist: &InstalledDist,
180 ) -> Result<ArchiveMetadata, Error> {
181 if let Some(metadata) = self
183 .build_context
184 .dependency_metadata()
185 .get(dist.name(), Some(dist.version()))
186 {
187 return Ok(ArchiveMetadata::from_metadata23(metadata));
188 }
189
190 let metadata = dist
191 .read_metadata()
192 .map_err(|err| Error::ReadInstalled(Box::new(dist.clone()), err))?;
193
194 Ok(ArchiveMetadata::from_metadata23(metadata.clone()))
195 }
196
197 #[instrument(skip_all, fields(%dist))]
202 pub async fn get_or_build_wheel_metadata(
203 &self,
204 dist: &Dist,
205 hashes: MetadataHashPolicy<'_>,
206 ) -> Result<ArchiveMetadata, Error> {
207 match dist {
208 Dist::Built(built) => self.get_wheel_metadata(built, hashes).await,
209 Dist::Source(source) => {
210 self.build_wheel_metadata(&BuildableSource::Dist(source), hashes)
211 .await
212 }
213 }
214 }
215
216 async fn get_wheel(
221 &self,
222 dist: &BuiltDist,
223 hashes: ArchiveHashPolicy<'_>,
224 ) -> Result<LocalWheel, Error> {
225 match dist {
226 BuiltDist::Registry(wheels) => {
227 let wheel = wheels.best_wheel();
228 let url = wheel.file.url.to_url()?;
229 let size = wheel.file.size;
230
231 let wheel_entry = self.build_context.cache().entry(
233 CacheBucket::Wheels,
234 WheelCache::Index(&wheel.index).wheel_dir(wheel.name().as_ref()),
235 wheel.filename.cache_key(),
236 );
237
238 if url.scheme() == "file" {
240 let path = url
241 .to_file_path()
242 .map_err(|()| Error::NonFileUrl(url.clone()))?;
243 return self
244 .load_wheel(&path, &wheel.filename, wheel_entry, dist, hashes)
245 .await;
246 }
247
248 match self
250 .stream_wheel(
251 url.clone(),
252 dist.index(),
253 &wheel.filename,
254 size,
255 &wheel_entry,
256 dist,
257 hashes,
258 )
259 .await
260 {
261 Ok(archive) => Ok(LocalWheel {
262 dist: Dist::Built(dist.clone()),
263 archive: self
264 .build_context
265 .cache()
266 .archive(&archive.id)
267 .into_boxed_path(),
268 hashes: archive.hashes,
269 filename: wheel.filename.clone(),
270 cache: CacheInfo::default(),
271 build: None,
272 }),
273 Err(Error::Extract(name, err)) => {
274 if err.is_http_streaming_unsupported() {
275 warn!(
276 "Streaming unsupported for {dist}; downloading wheel to disk ({err})"
277 );
278 } else if err.is_http_streaming_failed() {
279 warn!("Streaming failed for {dist}; downloading wheel to disk ({err})");
280 } else {
281 return Err(Error::Extract(name, err));
282 }
283
284 let archive = self
287 .download_wheel(
288 url,
289 dist.index(),
290 &wheel.filename,
291 size,
292 &wheel_entry,
293 dist,
294 hashes,
295 )
296 .await?;
297
298 Ok(LocalWheel {
299 dist: Dist::Built(dist.clone()),
300 archive: self
301 .build_context
302 .cache()
303 .archive(&archive.id)
304 .into_boxed_path(),
305 hashes: archive.hashes,
306 filename: wheel.filename.clone(),
307 cache: CacheInfo::default(),
308 build: None,
309 })
310 }
311 Err(err) => Err(err),
312 }
313 }
314
315 BuiltDist::DirectUrl(wheel) => {
316 let wheel_entry = self.build_context.cache().entry(
318 CacheBucket::Wheels,
319 WheelCache::Url(&wheel.url).wheel_dir(wheel.name().as_ref()),
320 wheel.filename.cache_key(),
321 );
322
323 match self
325 .stream_wheel(
326 wheel.url.raw().clone(),
327 None,
328 &wheel.filename,
329 wheel.size,
330 &wheel_entry,
331 dist,
332 hashes,
333 )
334 .await
335 {
336 Ok(archive) => Ok(LocalWheel {
337 dist: Dist::Built(dist.clone()),
338 archive: self
339 .build_context
340 .cache()
341 .archive(&archive.id)
342 .into_boxed_path(),
343 hashes: archive.hashes,
344 filename: wheel.filename.clone(),
345 cache: CacheInfo::default(),
346 build: None,
347 }),
348 Err(Error::Extract(name, err)) => {
349 if err.is_http_streaming_unsupported() {
350 warn!(
351 "Streaming unsupported for {dist}; downloading wheel to disk ({err})"
352 );
353 } else if err.is_http_streaming_failed() {
354 warn!("Streaming failed for {dist}; downloading wheel to disk ({err})");
355 } else {
356 return Err(Error::Extract(name, err));
357 }
358
359 let archive = self
362 .download_wheel(
363 wheel.url.raw().clone(),
364 None,
365 &wheel.filename,
366 wheel.size,
367 &wheel_entry,
368 dist,
369 hashes,
370 )
371 .await?;
372 Ok(LocalWheel {
373 dist: Dist::Built(dist.clone()),
374 archive: self
375 .build_context
376 .cache()
377 .archive(&archive.id)
378 .into_boxed_path(),
379 hashes: archive.hashes,
380 filename: wheel.filename.clone(),
381 cache: CacheInfo::default(),
382 build: None,
383 })
384 }
385 Err(err) => Err(err),
386 }
387 }
388
389 BuiltDist::GitPath(wheel) => {
390 let fetch = self
392 .build_context
393 .git()
394 .fetch(
395 &wheel.git,
396 self.client.unmanaged.git_http_settings(wheel.git.url()),
397 self.build_context.cache().bucket(CacheBucket::Git),
398 self.reporter.clone().map(<dyn Reporter>::into_git_reporter),
399 )
400 .await?;
401
402 if wheel.git.lfs().enabled() && !fetch.lfs_ready() {
403 if GIT_LFS.is_err() {
404 return Err(Error::MissingWheelGitLfsArtifacts(
405 wheel.url.to_url(),
406 GitError::GitLfsNotFound,
407 ));
408 }
409 return Err(Error::MissingWheelGitLfsArtifacts(
410 wheel.url.to_url(),
411 GitError::GitLfsNotConfigured,
412 ));
413 }
414
415 let git_sha = fetch.git().precise().expect("Exact commit after checkout");
416 let cache_entry = self.build_context.cache().entry(
417 CacheBucket::Wheels,
418 WheelCache::Git(&wheel.url, git_sha.as_short_str()).root(),
419 wheel.filename.stem(),
420 );
421
422 let install_path = fetch.path().join(&wheel.install_path);
423
424 self.load_wheel(&install_path, &wheel.filename, cache_entry, dist, hashes)
425 .await
426 }
427
428 BuiltDist::Path(wheel) => {
429 let cache_entry = self.build_context.cache().entry(
430 CacheBucket::Wheels,
431 WheelCache::Url(&wheel.url).wheel_dir(wheel.name().as_ref()),
432 wheel.filename.cache_key(),
433 );
434
435 self.load_wheel(
436 &wheel.install_path,
437 &wheel.filename,
438 cache_entry,
439 dist,
440 hashes,
441 )
442 .await
443 }
444 }
445 }
446
447 async fn build_wheel(
453 &self,
454 dist: &SourceDist,
455 tags: &Tags,
456 hashes: ArchiveHashPolicy<'_>,
457 ) -> Result<LocalWheel, Error> {
458 let built_wheel = self
459 .builder
460 .download_and_build(&BuildableSource::Dist(dist), tags, hashes, &self.client)
461 .boxed_local()
462 .await?;
463
464 if !built_wheel.filename.is_compatible(tags) {
470 return if tags.is_cross() {
471 Err(Error::BuiltWheelIncompatibleTargetPlatform {
472 filename: built_wheel.filename,
473 python_platform: tags.python_platform().clone(),
474 python_version: PythonVersion {
475 version: tags.python_version(),
476 variant: if tags.is_freethreaded() {
477 PythonVariant::Freethreaded
478 } else {
479 PythonVariant::Default
480 },
481 },
482 })
483 } else {
484 Err(Error::BuiltWheelIncompatibleHostPlatform {
485 filename: built_wheel.filename,
486 python_platform: tags.python_platform().clone(),
487 python_version: PythonVersion {
488 version: tags.python_version(),
489 variant: if tags.is_freethreaded() {
490 PythonVariant::Freethreaded
491 } else {
492 PythonVariant::Default
493 },
494 },
495 })
496 };
497 }
498
499 let wheel_entry = CacheEntry::from_path(built_wheel.target.as_ref());
501 let _lock = Self::lock_wheel(&wheel_entry, &built_wheel.filename).await?;
502
503 match self.build_context.cache().resolve_link(&built_wheel.target) {
506 Ok(archive) => {
507 return Ok(LocalWheel {
508 dist: Dist::Source(dist.clone()),
509 archive: archive.into_boxed_path(),
510 filename: built_wheel.filename,
511 hashes: built_wheel.hashes,
512 cache: built_wheel.cache_info,
513 build: Some(built_wheel.build_info),
514 });
515 }
516 Err(err) if err.kind() == io::ErrorKind::NotFound => {}
517 Err(err) => return Err(Error::CacheRead(err)),
518 }
519
520 let id = self
522 .unzip_wheel(
523 &built_wheel.path,
524 &built_wheel.target,
525 DistRef::Source(dist),
526 )
527 .await?;
528
529 Ok(LocalWheel {
530 dist: Dist::Source(dist.clone()),
531 archive: self.build_context.cache().archive(&id).into_boxed_path(),
532 hashes: built_wheel.hashes,
533 filename: built_wheel.filename,
534 cache: built_wheel.cache_info,
535 build: Some(built_wheel.build_info),
536 })
537 }
538
539 async fn get_wheel_metadata(
544 &self,
545 dist: &BuiltDist,
546 hashes: MetadataHashPolicy<'_>,
547 ) -> Result<ArchiveMetadata, Error> {
548 let hash_policy = match hashes.validation {
549 HashValidation::None => {
550 let compute_hashes = match hashes.collection {
551 HashCollection::None => false,
552 HashCollection::Url | HashCollection::All => match dist {
553 BuiltDist::Registry(dist) => {
554 hashes.collection == HashCollection::All
557 && dist.best_wheel().file.hashes.is_empty()
558 }
559 BuiltDist::DirectUrl(dist) => parse_url_hashes(&dist.url).is_none(),
560 BuiltDist::Path(_) | BuiltDist::GitPath(_) => true,
561 },
562 };
563 if compute_hashes {
564 ArchiveHashPolicy::Generate
565 } else {
566 ArchiveHashPolicy::None
567 }
568 }
569 HashValidation::Any(_) | HashValidation::All(_) => hashes.validation.into(),
570 };
571
572 if hash_policy == ArchiveHashPolicy::Generate {
575 let wheel = self.get_wheel(dist, hash_policy).await?;
576 let metadata = if let Some(metadata) = self
578 .build_context
579 .dependency_metadata()
580 .get(dist.name(), Some(dist.version()))
581 {
582 metadata
583 } else {
584 wheel.metadata()?
585 };
586 let hashes = wheel.hashes;
587 return Ok(ArchiveMetadata {
588 metadata: Metadata::from_metadata23(metadata),
589 hashes,
590 });
591 }
592
593 if let Some(metadata) = self
595 .build_context
596 .dependency_metadata()
597 .get(dist.name(), Some(dist.version()))
598 {
599 return Ok(ArchiveMetadata::from_metadata23(metadata));
600 }
601
602 let result = self
603 .client
604 .managed(|client| {
605 client
606 .wheel_metadata(
607 dist,
608 self.build_context.git(),
609 self.build_context.capabilities(),
610 self.reporter.clone().map(<dyn Reporter>::into_git_reporter),
611 )
612 .boxed_local()
613 })
614 .await;
615
616 match result {
617 Ok(metadata) => {
618 Ok(ArchiveMetadata::from_metadata23(metadata))
620 }
621 Err(err) if err.is_http_streaming_unsupported() => {
622 warn!(
623 "Streaming unsupported when fetching metadata for {dist}; downloading wheel directly ({err})"
624 );
625
626 let wheel = self.get_wheel(dist, hash_policy).await?;
629 let metadata = wheel.metadata()?;
630 let hashes = wheel.hashes;
631 Ok(ArchiveMetadata {
632 metadata: Metadata::from_metadata23(metadata),
633 hashes,
634 })
635 }
636 Err(err) => Err(err.into()),
637 }
638 }
639
640 pub async fn build_wheel_metadata(
645 &self,
646 source: &BuildableSource<'_>,
647 hashes: MetadataHashPolicy<'_>,
648 ) -> Result<ArchiveMetadata, Error> {
649 if let Some(dist) = source.as_dist() {
651 if let Some(metadata) = self
652 .build_context
653 .dependency_metadata()
654 .get(dist.name(), dist.version())
655 {
656 self.builder.resolve_revision(source, &self.client).await?;
659
660 return Ok(ArchiveMetadata::from_metadata23(metadata));
661 }
662 }
663
664 let url_hashes = if let BuildableSource::Dist(SourceDist::DirectUrl(dist)) = source {
665 parse_url_hashes(&dist.url)
666 } else if let BuildableSource::Url(SourceUrl::Direct(url)) = source {
667 parse_url_hashes(url.url)
668 } else {
669 None
670 };
671
672 let build_hash_policy = match hashes.validation {
673 HashValidation::None => match hashes.collection {
674 HashCollection::None => ArchiveHashPolicy::None,
675 HashCollection::Url | HashCollection::All => match url_hashes.as_ref() {
678 Some(digests) => ArchiveHashPolicy::All(digests.as_slice()),
679 None => ArchiveHashPolicy::Generate,
680 },
681 },
682 HashValidation::Any(_) | HashValidation::All(_) => hashes.validation.into(),
683 };
684 let metadata = self
685 .builder
686 .download_and_build_metadata(source, build_hash_policy, &self.client)
687 .boxed_local()
688 .await?;
689
690 Ok(metadata)
691 }
692
693 pub async fn requires_dist(
695 &self,
696 path: &Path,
697 pyproject_toml: &PyProjectToml,
698 ) -> Result<Option<RequiresDist>, Error> {
699 self.builder
700 .source_tree_requires_dist(
701 path,
702 pyproject_toml,
703 self.client.unmanaged.credentials_cache(),
704 )
705 .await
706 }
707
708 async fn stream_wheel(
714 &self,
715 url: DisplaySafeUrl,
716 index: Option<&IndexUrl>,
717 filename: &WheelFilename,
718 progress_size_hint: Option<u64>,
719 wheel_entry: &CacheEntry,
720 dist: &BuiltDist,
721 hashes: ArchiveHashPolicy<'_>,
722 ) -> Result<Archive, Error> {
723 let expected_size = match dist {
725 BuiltDist::Registry(dist) if dist.best_wheel().size_is_authoritative => {
726 progress_size_hint
727 }
728 BuiltDist::DirectUrl(_) => progress_size_hint,
729 _ => None,
730 };
731
732 let _lock = Self::lock_wheel(wheel_entry, filename).await?;
734
735 let http_entry = wheel_entry.with_file(format!("{}.http", filename.cache_key()));
737
738 let download = |response: reqwest::Response, _: &mut RetryState| {
739 async {
740 let progress_size_hint = progress_size_hint.or_else(|| content_length(&response));
741
742 let progress = self.reporter.as_ref().map(|reporter| {
743 (
744 reporter,
745 reporter.on_download_start(dist.name(), progress_size_hint),
746 )
747 });
748
749 let reader = response
750 .bytes_stream()
751 .map_err(|err| self.handle_response_errors(err))
752 .into_async_read();
753
754 let algorithms = http_hash_algorithms(hashes);
756 let mut hashers = algorithms.into_iter().map(Hasher::from).collect::<Vec<_>>();
757 let mut hasher = uv_extract::hash::HashReader::new(reader.compat(), &mut hashers);
758
759 let extractor = WheelExtractor::new(
761 self.build_context.cache().root(),
762 self.content_addressed_cache,
763 )
764 .map_err(Error::CacheWrite)?;
765
766 let mut extracted = match progress {
767 Some((reporter, progress)) => {
768 let mut reader = ProgressReader::new(&mut hasher, progress, &**reporter);
769 extractor
770 .extract_streaming(&mut reader)
771 .await
772 .map_err(|err| Error::Extract(filename.to_string(), err))?
773 }
774 None => extractor
775 .extract_streaming(&mut hasher)
776 .await
777 .map_err(|err| Error::Extract(filename.to_string(), err))?,
778 };
779 hasher.finish().await.map_err(Error::HashExhaustion)?;
781 let actual_size = hasher.bytes_read();
782 if let Some(expected) = expected_size
783 && actual_size != expected
784 {
785 return Err(Error::MismatchedSize {
786 distribution: dist.to_string(),
787 expected,
788 actual: actual_size,
789 });
790 }
791
792 extracted.validate_and_heal_record(dist)?;
795
796 let id = self
798 .persist_extracted_wheel(extracted, wheel_entry.path())
799 .await?;
800
801 if let Some((reporter, progress)) = progress {
802 reporter.on_download_complete(dist.name(), progress);
803 }
804
805 Ok(Archive::new(
806 id,
807 hashers.into_iter().map(HashDigest::from).collect(),
808 filename.clone(),
809 Some(actual_size),
810 ))
811 }
812 .instrument(info_span!("wheel", wheel = %dist))
813 };
814
815 let req = self.request(url.clone())?;
817
818 let cache_control = match self.client.unmanaged.connectivity() {
820 Connectivity::Online
821 if let Some(header) = index.and_then(|index| {
822 self.build_context
823 .locations()
824 .artifact_cache_control_for(index)
825 }) =>
826 {
827 CacheControl::Override(header)
828 }
829 Connectivity::Online => CacheControl::from(
830 self.build_context
831 .cache()
832 .freshness(&http_entry, Some(&filename.name), None)
833 .map_err(Error::CacheRead)?,
834 ),
835 Connectivity::Offline => CacheControl::AllowStale,
836 };
837
838 let archive = self
839 .client
840 .managed(|client| {
841 client.cached_client().get_serde_with_retry(
842 req,
843 &http_entry,
844 cache_control.clone(),
845 download,
846 )
847 })
848 .await
849 .map_err(|err| match err {
850 CachedClientError::Callback { err, .. } => err,
851 CachedClientError::Client(err) => Error::Client(err),
852 })?;
853
854 if let (Some(expected), Some(actual)) = (expected_size, archive.size)
855 && expected != actual
856 {
857 return Err(Error::MismatchedSize {
858 distribution: dist.to_string(),
859 expected,
860 actual,
861 });
862 }
863
864 let archive = Some(archive)
866 .filter(|archive| archive.has_digests(hashes))
867 .filter(|archive| archive.exists(self.build_context.cache()))
868 .filter(|archive| expected_size.is_none() || archive.size.is_some());
869
870 let archive = if let Some(archive) = archive {
871 archive
872 } else {
873 self.client
874 .managed(async |client| {
875 client
876 .cached_client()
877 .skip_cache_with_retry(
878 self.request(url)?,
879 &http_entry,
880 cache_control,
881 download,
882 )
883 .await
884 .map_err(|err| match err {
885 CachedClientError::Callback { err, .. } => err,
886 CachedClientError::Client(err) => Error::Client(err),
887 })
888 })
889 .await?
890 };
891
892 Ok(archive)
893 }
894
895 async fn download_wheel(
901 &self,
902 url: DisplaySafeUrl,
903 index: Option<&IndexUrl>,
904 filename: &WheelFilename,
905 progress_size_hint: Option<u64>,
906 wheel_entry: &CacheEntry,
907 dist: &BuiltDist,
908 hashes: ArchiveHashPolicy<'_>,
909 ) -> Result<Archive, Error> {
910 let expected_size = match dist {
912 BuiltDist::Registry(dist) if dist.best_wheel().size_is_authoritative => {
913 progress_size_hint
914 }
915 BuiltDist::DirectUrl(_) => progress_size_hint,
916 _ => None,
917 };
918
919 let _lock = Self::lock_wheel(wheel_entry, filename).await?;
921
922 let http_entry = wheel_entry.with_file(format!("{}.http", filename.cache_key()));
924
925 let download_url = url.clone();
926
927 let download = async |response, retry_state: &mut RetryState| {
928 self.download_wheel_response(
929 response,
930 &download_url,
931 retry_state,
932 filename,
933 progress_size_hint,
934 expected_size,
935 wheel_entry,
936 dist,
937 hashes,
938 )
939 .instrument(info_span!("wheel", wheel = %dist))
940 .await
941 };
942
943 let req = self.request(url.clone())?;
945
946 let cache_control = match self.client.unmanaged.connectivity() {
948 Connectivity::Online
949 if let Some(header) = index.and_then(|index| {
950 self.build_context
951 .locations()
952 .artifact_cache_control_for(index)
953 }) =>
954 {
955 CacheControl::Override(header)
956 }
957 Connectivity::Online => CacheControl::from(
958 self.build_context
959 .cache()
960 .freshness(&http_entry, Some(&filename.name), None)
961 .map_err(Error::CacheRead)?,
962 ),
963 Connectivity::Offline => CacheControl::AllowStale,
964 };
965
966 let archive = self
967 .client
968 .managed(|client| {
969 client.cached_client().get_serde_with_retry(
970 req,
971 &http_entry,
972 cache_control.clone(),
973 download,
974 )
975 })
976 .await
977 .map_err(|err| match err {
978 CachedClientError::Callback { err, .. } => err,
979 CachedClientError::Client(err) => Error::Client(err),
980 })?;
981
982 if let (Some(expected), Some(actual)) = (expected_size, archive.size)
983 && expected != actual
984 {
985 return Err(Error::MismatchedSize {
986 distribution: dist.to_string(),
987 expected,
988 actual,
989 });
990 }
991
992 let archive = Some(archive)
994 .filter(|archive| archive.has_digests(hashes))
995 .filter(|archive| archive.exists(self.build_context.cache()))
996 .filter(|archive| expected_size.is_none() || archive.size.is_some());
997
998 let archive = if let Some(archive) = archive {
999 archive
1000 } else {
1001 self.client
1002 .managed(async |client| {
1003 client
1004 .cached_client()
1005 .skip_cache_with_retry(
1006 self.request(url)?,
1007 &http_entry,
1008 cache_control,
1009 download,
1010 )
1011 .await
1012 .map_err(|err| match err {
1013 CachedClientError::Callback { err, .. } => err,
1014 CachedClientError::Client(err) => Error::Client(err),
1015 })
1016 })
1017 .await?
1018 };
1019
1020 Ok(archive)
1021 }
1022
1023 async fn download_wheel_response(
1031 &self,
1032 mut response: reqwest::Response,
1033 url: &DisplaySafeUrl,
1034 retry_state: &mut RetryState,
1035 filename: &WheelFilename,
1036 progress_size_hint: Option<u64>,
1037 expected_size: Option<u64>,
1038 wheel_entry: &CacheEntry,
1039 dist: &BuiltDist,
1040 hashes: ArchiveHashPolicy<'_>,
1041 ) -> Result<Archive, Error> {
1042 let progress_size_hint = progress_size_hint.or_else(|| content_length(&response));
1043 let mut download_size = content_length(&response).or(expected_size);
1044
1045 let progress = self.reporter.as_ref().map(|reporter| {
1046 (
1047 reporter,
1048 reporter.on_download_start(dist.name(), progress_size_hint),
1049 )
1050 });
1051
1052 let algorithms = http_hash_algorithms(hashes);
1053
1054 let temp_file =
1056 tempfile::tempfile_in(self.build_context.cache().root()).map_err(Error::CacheWrite)?;
1057 let mut writer = tokio::io::BufWriter::new(fs_err::tokio::File::from_std(
1058 fs_err::File::from_parts(temp_file, self.build_context.cache().root()),
1060 ));
1061
1062 let mut hashers = algorithms.into_iter().map(Hasher::from).collect::<Vec<_>>();
1064 let mut bytes_retrieved = 0;
1068 let mut resumed_at = None;
1070 let mut resumed_range: Option<ContentRangeBytes> = None;
1072
1073 loop {
1079 if response.status() == reqwest::StatusCode::OK
1082 && let (Some(expected), Some(actual)) = (expected_size, content_length(&response))
1083 && expected != actual
1084 {
1085 return Err(Error::MismatchedContentLength {
1086 distribution: dist.to_string(),
1087 expected,
1088 actual,
1089 });
1090 }
1091
1092 let supports_range_requests = response.status() == reqwest::StatusCode::PARTIAL_CONTENT
1095 || response
1096 .headers()
1097 .get(reqwest::header::ACCEPT_RANGES)
1098 .is_some_and(|value| value == "bytes");
1099
1100 let replaces_partial_download =
1103 resumed_at.is_some() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT;
1104 if replaces_partial_download {
1105 writer
1106 .get_mut()
1107 .set_len(0)
1108 .await
1109 .map_err(Error::CacheWrite)?;
1110 writer
1111 .seek(io::SeekFrom::Start(0))
1112 .await
1113 .map_err(Error::CacheWrite)?;
1114 hashers = http_hash_algorithms(hashes)
1115 .into_iter()
1116 .map(Hasher::from)
1117 .collect();
1118 bytes_retrieved = 0;
1119 }
1120
1121 let reader = response
1122 .bytes_stream()
1123 .map_err(|err| self.handle_response_errors(err))
1124 .into_async_read();
1125 let mut hasher = uv_extract::hash::HashReader::new(reader.compat(), &mut hashers);
1126
1127 let copy_result = match progress {
1131 Some((reporter, progress)) => {
1132 let mut reader = ProgressReader::new(&mut hasher, progress, &**reporter);
1135
1136 tokio::io::copy(&mut reader, &mut writer)
1137 .await
1138 .map_err(Error::CacheWrite)
1139 }
1140 None => tokio::io::copy(&mut hasher, &mut writer)
1141 .await
1142 .map_err(Error::CacheWrite),
1143 };
1144
1145 bytes_retrieved += hasher.bytes_read();
1146
1147 let interrupted = copy_result.is_err();
1148 let err = match copy_result {
1149 Ok(_) => {
1152 let Some(range) = resumed_range else {
1155 break;
1156 };
1157
1158 if bytes_retrieved != range.last_byte + 1 {
1164 return Err(Error::MismatchedRangeSize {
1165 distribution: dist.to_string(),
1166 expected: range.last_byte - range.first_byte + 1,
1167 actual: hasher.bytes_read(),
1168 });
1169 }
1170
1171 if bytes_retrieved == range.complete_length {
1174 break;
1175 }
1176
1177 Error::CacheWrite(io::Error::new(
1183 io::ErrorKind::UnexpectedEof,
1184 "Range response did not complete the download",
1185 ))
1186 }
1187 Err(err) => err,
1190 };
1191
1192 if replaces_partial_download || !supports_range_requests {
1201 return Err(err);
1202 }
1203
1204 writer.flush().await.map_err(Error::CacheWrite)?;
1209 let offset = writer
1210 .get_mut()
1211 .stream_position()
1212 .await
1213 .map_err(Error::CacheWrite)?;
1214 if offset == 0
1215 || offset != bytes_retrieved
1216 || resumed_at.is_some_and(|previous| offset <= previous)
1217 {
1218 return Err(err);
1219 }
1220
1221 if interrupted {
1224 let Some(backoff) = retry_state.should_retry(&err, 0) else {
1225 return Err(err);
1226 };
1227 retry_state.sleep_backoff(backoff).await;
1228 }
1229
1230 debug!("Resuming download of {url} at byte {offset}");
1232 let resumed_response = retry_state
1233 .send(self.request_with_offset(url.clone(), offset))
1234 .await?;
1235
1236 if resumed_response.status() == reqwest::StatusCode::RANGE_NOT_SATISFIABLE {
1239 debug!("Range not satisfiable while resuming {url}; abandoning resumed download");
1240 return Err(err);
1241 }
1242 resumed_response.error_for_status_ref()?;
1243
1244 resumed_range = if resumed_response.status() == reqwest::StatusCode::PARTIAL_CONTENT {
1245 let Some(range) = content_range(&resumed_response, offset, download_size) else {
1248 warn!(
1249 "Invalid range request response from server that declares HTTP range \
1250 request support, abandoning resumed download: {url}"
1251 );
1252 return Err(err);
1253 };
1254 download_size = Some(range.complete_length);
1255 Some(range)
1256 } else {
1257 None
1260 };
1261
1262 response = resumed_response;
1263 resumed_at = Some(offset);
1264 }
1265
1266 if let Some(expected) = expected_size
1269 && bytes_retrieved != expected
1270 {
1271 return Err(Error::MismatchedSize {
1272 distribution: dist.to_string(),
1273 expected,
1274 actual: bytes_retrieved,
1275 });
1276 }
1277
1278 let extractor = WheelExtractor::new(
1280 self.build_context.cache().root(),
1281 self.content_addressed_cache,
1282 )
1283 .map_err(Error::CacheWrite)?;
1284 let mut file = writer.into_inner();
1285 file.seek(io::SeekFrom::Start(0))
1286 .await
1287 .map_err(Error::CacheWrite)?;
1288
1289 let file = file.into_std().await;
1290 let mut extracted = tokio::task::spawn_blocking(move || extractor.extract_seekable(file))
1291 .await?
1292 .map_err(|err| Error::Extract(filename.to_string(), err))?;
1293 let hashes = hashers.into_iter().map(HashDigest::from).collect();
1294
1295 extracted.validate_and_heal_record(dist)?;
1298
1299 let id = self
1301 .persist_extracted_wheel(extracted, wheel_entry.path())
1302 .await?;
1303
1304 if let Some((reporter, progress)) = progress {
1305 reporter.on_download_complete(dist.name(), progress);
1306 }
1307
1308 Ok(Archive::new(
1309 id,
1310 hashes,
1311 filename.clone(),
1312 Some(bytes_retrieved),
1313 ))
1314 }
1315
1316 async fn load_wheel(
1318 &self,
1319 path: &Path,
1320 filename: &WheelFilename,
1321 wheel_entry: CacheEntry,
1322 dist: &BuiltDist,
1323 hashes: ArchiveHashPolicy<'_>,
1324 ) -> Result<LocalWheel, Error> {
1325 let _lock = Self::lock_wheel(&wheel_entry, filename).await?;
1327
1328 let modified = Timestamp::from_path(path).map_err(Error::CacheRead)?;
1330
1331 let pointer_entry = wheel_entry.with_file(format!("{}.rev", filename.cache_key()));
1333 let pointer = PathArchivePointer::read_from(&pointer_entry)?;
1334
1335 let archive = pointer
1337 .filter(|pointer| pointer.is_up_to_date(modified))
1338 .map(PathArchivePointer::into_archive)
1339 .filter(|archive| archive.has_digests(hashes));
1340
1341 if let Some(archive) = archive {
1343 Ok(LocalWheel {
1344 dist: Dist::Built(dist.clone()),
1345 archive: self
1346 .build_context
1347 .cache()
1348 .archive(&archive.id)
1349 .into_boxed_path(),
1350 hashes: archive.hashes,
1351 filename: filename.clone(),
1352 cache: CacheInfo::from_timestamp(modified),
1353 build: None,
1354 })
1355 } else if hashes.is_none() {
1356 let archive = Archive::new(
1358 self.unzip_wheel(path, wheel_entry.path(), DistRef::Built(dist))
1359 .await?,
1360 HashDigests::empty(),
1361 filename.clone(),
1362 None,
1363 );
1364
1365 let pointer = PathArchivePointer {
1367 timestamp: modified,
1368 archive: archive.clone(),
1369 };
1370 pointer.write_to(&pointer_entry).await?;
1371
1372 Ok(LocalWheel {
1373 dist: Dist::Built(dist.clone()),
1374 archive: self
1375 .build_context
1376 .cache()
1377 .archive(&archive.id)
1378 .into_boxed_path(),
1379 hashes: archive.hashes,
1380 filename: filename.clone(),
1381 cache: CacheInfo::from_timestamp(modified),
1382 build: None,
1383 })
1384 } else {
1385 let file = fs_err::tokio::File::open(path)
1387 .await
1388 .map_err(Error::CacheRead)?;
1389 let extractor = WheelExtractor::new(
1390 self.build_context.cache().root(),
1391 self.content_addressed_cache,
1392 )
1393 .map_err(Error::CacheWrite)?;
1394
1395 let algorithms = hashes.algorithms();
1397 let mut hashers = algorithms.into_iter().map(Hasher::from).collect::<Vec<_>>();
1398 let mut hasher = uv_extract::hash::HashReader::new(file, &mut hashers);
1399
1400 let mut extracted = extractor
1402 .extract_streaming(&mut hasher)
1403 .await
1404 .map_err(|err| Error::Extract(filename.to_string(), err))?;
1405
1406 hasher.finish().await.map_err(Error::HashExhaustion)?;
1408
1409 let hashes = hashers.into_iter().map(HashDigest::from).collect();
1410
1411 extracted.validate_and_heal_record(dist)?;
1414
1415 let id = self
1417 .persist_extracted_wheel(extracted, wheel_entry.path())
1418 .await?;
1419
1420 let archive = Archive::new(id, hashes, filename.clone(), None);
1422
1423 let pointer = PathArchivePointer {
1425 timestamp: modified,
1426 archive: archive.clone(),
1427 };
1428 pointer.write_to(&pointer_entry).await?;
1429
1430 Ok(LocalWheel {
1431 dist: Dist::Built(dist.clone()),
1432 archive: self
1433 .build_context
1434 .cache()
1435 .archive(&archive.id)
1436 .into_boxed_path(),
1437 hashes: archive.hashes,
1438 filename: filename.clone(),
1439 cache: CacheInfo::from_timestamp(modified),
1440 build: None,
1441 })
1442 }
1443 }
1444
1445 async fn unzip_wheel(
1447 &self,
1448 path: &Path,
1449 target: &Path,
1450 dist: DistRef<'_>,
1451 ) -> Result<ArchiveId, Error> {
1452 let content_addressed_cache = self.content_addressed_cache;
1453
1454 let mut extracted = tokio::task::spawn_blocking({
1455 let path = path.to_owned();
1456 let root = self.build_context.cache().root().to_path_buf();
1457 move || -> Result<_, Error> {
1458 let extractor = WheelExtractor::new(&root, content_addressed_cache)
1460 .map_err(Error::CacheWrite)?;
1461 let reader = fs_err::File::open(&path).map_err(Error::CacheWrite)?;
1462 extractor
1463 .extract_seekable(reader)
1464 .map_err(|err| Error::Extract(path.to_string_lossy().into_owned(), err))
1465 }
1466 })
1467 .await??;
1468
1469 extracted.validate_and_heal_record(dist)?;
1471
1472 let id = self.persist_extracted_wheel(extracted, target).await?;
1474
1475 Ok(id)
1476 }
1477
1478 async fn persist_extracted_wheel(
1483 &self,
1484 extracted: ExtractedWheel,
1485 target: &Path,
1486 ) -> Result<ArchiveId, Error> {
1487 let (temp_dir, hashed_wheel) = extracted.into_parts();
1488 let cache = self.build_context.cache();
1489 let (temp_dir, id) = if let Some(HashedWheel { files, tree }) = hashed_wheel {
1490 let digest = DirectoryDigest::from(tree.hash());
1491 let id = ArchiveId::from_digest(digest.into());
1492 let cache = cache.clone();
1493 let temp_dir = tokio::task::spawn_blocking(move || {
1494 persist_archive_files(&cache, temp_dir.path(), &files)
1495 .map_err(Error::CacheWrite)?;
1496 Ok::<_, Error>(temp_dir)
1497 })
1498 .await??;
1499 (temp_dir, id)
1500 } else {
1501 (temp_dir, ArchiveId::default())
1502 };
1503
1504 cache
1505 .persist_with_id(temp_dir, target, id)
1506 .await
1507 .map_err(Error::CacheWrite)
1508 }
1509
1510 fn request(&self, url: DisplaySafeUrl) -> Result<reqwest::Request, reqwest::Error> {
1512 self.client
1513 .unmanaged
1514 .uncached_client(&url)
1515 .get(Url::from(url))
1516 .header(
1517 "accept-encoding",
1521 reqwest::header::HeaderValue::from_static("identity"),
1522 )
1523 .build()
1524 }
1525
1526 fn request_with_offset(&self, url: DisplaySafeUrl, offset: u64) -> RequestBuilder<'_> {
1530 self.client
1531 .unmanaged
1532 .uncached_client(&url)
1533 .get(Url::from(url))
1534 .header(
1535 "accept-encoding",
1536 reqwest::header::HeaderValue::from_static("identity"),
1537 )
1538 .header(reqwest::header::RANGE, format!("bytes={offset}-"))
1539 }
1540
1541 pub fn client(&self) -> &ManagedClient<'a> {
1543 &self.client
1544 }
1545}
1546
1547fn persist_archive_files(cache: &Cache, archive: &Path, files: &[HashedFile]) -> io::Result<()> {
1549 initialize_rayon_once();
1550 let targets = files
1551 .par_iter()
1552 .filter(|file| !file.path().ends_with("RECORD"))
1554 .map(|file| {
1555 let id = ArchiveFileId::from_digest(&file.object_digest_hex());
1556 (archive.join(file.path()), cache.archive_file(&id))
1557 })
1558 .collect::<Vec<_>>();
1559
1560 let mut shards: FxHashMap<&Path, Vec<_>> = FxHashMap::default();
1563 for (source, target) in &targets {
1564 let Some(parent) = target.parent() else {
1565 return Err(io::Error::new(
1566 io::ErrorKind::InvalidInput,
1567 "archive file path must have a parent directory",
1568 ));
1569 };
1570 shards.entry(parent).or_default().push((source, target));
1571 }
1572
1573 let mut shards = shards
1574 .into_iter()
1575 .map(|(parent, files)| (parent, files, Ok(())))
1576 .collect::<Vec<_>>();
1577 shards.sort_unstable_by_key(|(_, files, _)| Reverse(files.len()));
1579
1580 in_place_scope(|scope| -> io::Result<()> {
1583 for (parent, files, result) in &mut shards {
1584 fs_err::create_dir_all(parent)?;
1585 scope.spawn(move |_| {
1586 *result = files
1587 .iter()
1588 .try_for_each(|(source, target)| persist_archive_file(source, target));
1589 });
1590 }
1591 Ok(())
1592 })?;
1593
1594 shards.into_iter().try_for_each(|(_, _, result)| result)
1595}
1596
1597fn persist_archive_file(src: &Path, dst: &Path) -> io::Result<()> {
1599 match fs_err::hard_link(src, dst) {
1602 Ok(()) => return Ok(()),
1603 Err(_) if dst.try_exists()? => {}
1604 Err(_) => return uv_fs::copy_atomic_sync(src, dst),
1605 }
1606
1607 if let Err(err) = fs_err::remove_file(src)
1609 && err.kind() != io::ErrorKind::NotFound
1610 {
1611 return Err(err);
1612 }
1613
1614 fs_err::hard_link(dst, src).or_else(|_| uv_fs::copy_atomic_sync(dst, src))
1615}
1616
1617pub struct ManagedClient<'a> {
1619 pub unmanaged: &'a RegistryClient,
1620 control: Arc<Semaphore>,
1621}
1622
1623impl<'a> ManagedClient<'a> {
1624 fn new(client: &'a RegistryClient, control: Arc<Semaphore>) -> Self {
1626 ManagedClient {
1627 unmanaged: client,
1628 control,
1629 }
1630 }
1631
1632 pub async fn managed<F, T>(&self, f: impl FnOnce(&'a RegistryClient) -> F) -> T
1637 where
1638 F: Future<Output = T>,
1639 {
1640 let _permit = self.control.acquire().await.unwrap();
1641 f(self.unmanaged).await
1642 }
1643
1644 pub async fn manual<F, T>(&'a self, f: impl FnOnce(&'a RegistryClient, &'a Semaphore) -> F) -> T
1652 where
1653 F: Future<Output = T>,
1654 {
1655 f(self.unmanaged, &self.control).await
1656 }
1657}
1658
1659fn content_length(response: &reqwest::Response) -> Option<u64> {
1661 response
1662 .headers()
1663 .get(reqwest::header::CONTENT_LENGTH)
1664 .and_then(|val| val.to_str().ok())
1665 .and_then(|val| val.parse::<u64>().ok())
1666}
1667
1668fn content_range(
1670 response: &reqwest::Response,
1671 offset: u64,
1672 download_size: Option<u64>,
1673) -> Option<ContentRangeBytes> {
1674 let range = response
1675 .headers()
1676 .get(reqwest::header::CONTENT_RANGE)
1677 .and_then(|value| value.to_str().ok())
1678 .and_then(ContentRange::parse)?;
1679
1680 let range = match range {
1681 ContentRange::Bytes(range) => range,
1682 ContentRange::UnboundBytes(ContentRangeUnbound {
1683 first_byte,
1684 last_byte,
1685 }) => ContentRangeBytes {
1686 first_byte,
1687 last_byte,
1688 complete_length: download_size?,
1689 },
1690 ContentRange::Unsatisfied(_) => return None,
1691 };
1692
1693 if range.first_byte != offset
1694 || range.last_byte >= range.complete_length
1695 || download_size.is_some_and(|size| size != range.complete_length)
1696 {
1697 return None;
1698 }
1699
1700 Some(range)
1701}
1702
1703struct ProgressReader<'a, R> {
1705 reader: R,
1706 index: usize,
1707 reporter: &'a dyn Reporter,
1708}
1709
1710impl<'a, R> ProgressReader<'a, R> {
1711 fn new(reader: R, index: usize, reporter: &'a dyn Reporter) -> Self {
1713 Self {
1714 reader,
1715 index,
1716 reporter,
1717 }
1718 }
1719}
1720
1721impl<R> AsyncRead for ProgressReader<'_, R>
1722where
1723 R: AsyncRead + Unpin,
1724{
1725 fn poll_read(
1726 mut self: Pin<&mut Self>,
1727 cx: &mut Context<'_>,
1728 buf: &mut ReadBuf<'_>,
1729 ) -> Poll<io::Result<()>> {
1730 Pin::new(&mut self.as_mut().reader)
1731 .poll_read(cx, buf)
1732 .map_ok(|()| {
1733 self.reporter
1734 .on_download_progress(self.index, buf.filled().len() as u64);
1735 })
1736 }
1737}
1738
1739#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1743pub struct HttpArchivePointer {
1744 archive: Archive,
1745}
1746
1747impl HttpArchivePointer {
1748 pub fn read_from(path: impl AsRef<Path>) -> Result<Option<Self>, Error> {
1750 match fs_err::File::open(path.as_ref()) {
1751 Ok(file) => {
1752 let data = DataWithCachePolicy::from_reader(file)?.data;
1753 let archive = rmp_serde::from_slice::<Archive>(&data)?;
1754 Ok(Some(Self { archive }))
1755 }
1756 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
1757 Err(err) => Err(Error::CacheRead(err)),
1758 }
1759 }
1760
1761 pub fn into_archive(self) -> Archive {
1763 self.archive
1764 }
1765
1766 pub fn to_cache_info(&self) -> CacheInfo {
1768 CacheInfo::default()
1769 }
1770
1771 pub fn to_build_info(&self) -> Option<BuildInfo> {
1773 None
1774 }
1775}
1776
1777#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1781pub struct PathArchivePointer {
1782 timestamp: Timestamp,
1783 archive: Archive,
1784}
1785
1786impl PathArchivePointer {
1787 pub fn read_from(path: impl AsRef<Path>) -> Result<Option<Self>, Error> {
1789 match fs_err::read(path) {
1790 Ok(cached) => Ok(Some(rmp_serde::from_slice::<Self>(&cached)?)),
1791 Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
1792 Err(err) => Err(Error::CacheRead(err)),
1793 }
1794 }
1795
1796 async fn write_to(&self, entry: &CacheEntry) -> Result<(), Error> {
1798 write_atomic(entry.path(), rmp_serde::to_vec(&self)?)
1799 .await
1800 .map_err(Error::CacheWrite)
1801 }
1802
1803 pub fn is_up_to_date(&self, modified: Timestamp) -> bool {
1805 self.timestamp == modified
1806 }
1807
1808 pub fn into_archive(self) -> Archive {
1810 self.archive
1811 }
1812
1813 pub fn to_cache_info(&self) -> CacheInfo {
1815 CacheInfo::from_timestamp(self.timestamp)
1816 }
1817
1818 pub fn to_build_info(&self) -> Option<BuildInfo> {
1820 None
1821 }
1822}