1mod providers;
2
3#[cfg(unix)]
4use std::os::unix::fs::PermissionsExt;
5
6use providers::{BinstallProvider, GithubProvider, GitlabProvider, Provider, QuickinstallProvider};
7use serde::{Deserialize, Serialize};
8use snafu::{IntoError, ResultExt};
9use tempfile::TempDir;
10use tracing::warn;
11
12use crate::{
13 Result,
14 builder::{BuildOptions, BuildTarget},
15 cache::Cache,
16 config::{BinaryProvider, Config, UsePrebuiltBinaries},
17 crate_resolver::ResolvedCrate,
18 downloader::DownloadedCrate,
19 error::{self, Error},
20 http::HttpClient,
21 messages::{MessageReporter, PrebuiltBinaryMessage, ProviderChangeReason},
22 target::TargetTriple,
23};
24
25#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
30pub struct ResolvedBinary {
31 pub krate: ResolvedCrate,
33
34 pub provider: BinaryProvider,
36
37 pub path: std::path::PathBuf,
39
40 pub target: String,
47}
48
49pub trait BinaryResolver {
50 fn resolve(
58 &self,
59 krate: &DownloadedCrate,
60 build_options: &BuildOptions,
61 ) -> Result<Option<ResolvedBinary>>;
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(tag = "outcome", rename_all = "snake_case")]
76#[expect(
77 clippy::large_enum_variant,
78 reason = "only a handful of these exist at a time (one per resolved crate); the size disparity between \
79 Found and Nonexistent does not matter and boxing would only add indirection"
80)]
81pub(crate) enum ConclusiveResolution {
82 Found(ResolvedBinary),
84 Nonexistent,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub(crate) struct BinaryCacheEntry {
92 #[serde(flatten)]
93 pub(crate) outcome: ConclusiveResolution,
94 pub(crate) enabled_providers: Vec<BinaryProvider>,
96}
97
98pub(crate) fn create_resolver(
101 config: Config,
102 cache: Cache,
103 reporter: MessageReporter,
104 http_client: HttpClient,
105) -> Result<impl BinaryResolver> {
106 DefaultBinaryResolver::new(config, cache, reporter, http_client)
107}
108
109#[derive(Debug)]
110#[expect(
111 clippy::large_enum_variant,
112 reason = "this is a short-lived return value (a handful per resolution), never stored in bulk; boxing \
113 the common Found payload would only add a heap allocation to the success path"
114)]
115enum BinaryResolution {
116 Found(ResolvedBinary),
118 Nonexistent,
120 Inconclusive { source: Box<Error> },
124}
125
126impl BinaryResolution {
127 fn to_cacheable(&self) -> Option<ConclusiveResolution> {
134 match self {
135 BinaryResolution::Found(binary) => Some(ConclusiveResolution::Found(binary.clone())),
136 BinaryResolution::Nonexistent => Some(ConclusiveResolution::Nonexistent),
137 BinaryResolution::Inconclusive { .. } => None,
138 }
139 }
140}
141
142impl From<ConclusiveResolution> for BinaryResolution {
143 fn from(value: ConclusiveResolution) -> Self {
144 match value {
145 ConclusiveResolution::Found(binary) => Self::Found(binary),
146 ConclusiveResolution::Nonexistent => Self::Nonexistent,
147 }
148 }
149}
150
151struct DefaultBinaryResolver {
154 config: Config,
155 cache: Cache,
156 reporter: MessageReporter,
157 mode: UsePrebuiltBinaries,
158 #[expect(
161 dead_code,
162 reason = "held for its Drop impl: the staging directory must stay alive for the providers that \
163 write into it, and dropping it is what cleans the staging area up"
164 )]
165 staging: TempDir,
166 providers: Vec<Box<dyn Provider + Send + Sync>>,
167}
168
169impl DefaultBinaryResolver {
170 fn new(config: Config, cache: Cache, reporter: MessageReporter, http_client: HttpClient) -> Result<Self> {
171 let staging = Self::create_staging_dir(&config)?;
172 let verify = config.prebuilt_binaries.verify_checksums;
173
174 let providers = config
175 .prebuilt_binaries
176 .binary_providers
177 .iter()
178 .map(|provider_type| -> Box<dyn Provider + Send + Sync> {
179 match provider_type {
180 BinaryProvider::Binstall => Box::new(BinstallProvider::new(
181 reporter.clone(),
182 &staging,
183 verify,
184 http_client.clone(),
185 )),
186 BinaryProvider::GithubReleases => Box::new(GithubProvider::new(
187 reporter.clone(),
188 &staging,
189 verify,
190 http_client.clone(),
191 )),
192 BinaryProvider::GitlabReleases => Box::new(GitlabProvider::new(
193 reporter.clone(),
194 &staging,
195 verify,
196 http_client.clone(),
197 )),
198 BinaryProvider::Quickinstall => Box::new(QuickinstallProvider::new(
199 reporter.clone(),
200 &staging,
201 http_client.clone(),
202 )),
203 }
204 })
205 .collect();
206
207 Ok(Self::with_providers(config, cache, reporter, staging, providers))
208 }
209
210 fn create_staging_dir(config: &Config) -> Result<TempDir> {
213 std::fs::create_dir_all(&config.bin_dir).with_context(|_| error::IoSnafu {
214 path: config.bin_dir.clone(),
215 })?;
216 tempfile::Builder::new()
217 .prefix("cgx-bin-resolver-temp")
218 .tempdir_in(&config.bin_dir)
219 .with_context(|_| error::TempDirInCreationSnafu {
220 parent: config.bin_dir.clone(),
221 })
222 }
223
224 fn with_providers(
225 config: Config,
226 cache: Cache,
227 reporter: MessageReporter,
228 staging: TempDir,
229 providers: Vec<Box<dyn Provider + Send + Sync>>,
230 ) -> Self {
231 let mode = config.prebuilt_binaries.use_prebuilt_binaries;
232 Self {
233 config,
234 cache,
235 reporter,
236 mode,
237 staging,
238 providers,
239 }
240 }
241
242 fn is_disqualified(build_options: &BuildOptions) -> Option<&'static str> {
247 if build_options.build_target != BuildTarget::DefaultBin {
248 return Some("explicit --bin or --example specified");
249 }
250
251 if !build_options.features.is_empty() {
252 return Some("custom features specified");
253 }
254
255 if build_options.all_features {
256 return Some("--all-features specified");
257 }
258
259 if build_options.no_default_features {
260 return Some("--no-default-features specified");
261 }
262
263 if build_options.profile.is_some() {
264 return Some("custom profile specified");
265 }
266
267 if build_options.target.is_some() {
268 return Some("custom target specified");
269 }
270
271 if build_options.toolchain.is_some() {
272 return Some("custom toolchain specified");
273 }
274
275 None
276 }
277
278 fn combine_resolutions(resolutions: impl IntoIterator<Item = BinaryResolution>) -> BinaryResolution {
286 let mut inconclusive: Option<Box<Error>> = None;
287 for resolution in resolutions {
288 match resolution {
289 BinaryResolution::Found(binary) => return BinaryResolution::Found(binary),
290 BinaryResolution::Inconclusive { source } => {
291 inconclusive.get_or_insert(source);
292 }
293 BinaryResolution::Nonexistent => {}
294 }
295 }
296 match inconclusive {
297 Some(source) => BinaryResolution::Inconclusive { source },
298 None => BinaryResolution::Nonexistent,
299 }
300 }
301
302 fn apply_mode(
308 resolution: BinaryResolution,
309 mode: UsePrebuiltBinaries,
310 krate: &ResolvedCrate,
311 ) -> Result<Option<ResolvedBinary>> {
312 debug_assert_ne!(mode, UsePrebuiltBinaries::Never);
314
315 match resolution {
316 BinaryResolution::Found(binary) => Ok(Some(binary)),
317 BinaryResolution::Nonexistent => {
318 if mode == UsePrebuiltBinaries::Always {
319 error::PrebuiltBinaryRequiredSnafu {
320 name: krate.name.clone(),
321 version: krate.version.to_string(),
322 }
323 .fail()
324 } else {
325 Ok(None)
326 }
327 }
328 BinaryResolution::Inconclusive { source } => {
329 if mode == UsePrebuiltBinaries::Always {
330 Err(error::PrebuiltBinaryResolutionFailedSnafu {
331 name: krate.name.clone(),
332 version: krate.version.to_string(),
333 }
334 .into_error(source))
335 } else {
336 Ok(None)
337 }
338 }
339 }
340 }
341
342 fn get_cached_resolution(&self, krate: &ResolvedCrate) -> Option<ConclusiveResolution> {
359 let entry = self.cache.get_cached_binary_resolution(krate).ok()??;
360 let enabled = &self.config.prebuilt_binaries.binary_providers;
361
362 if let Some(missing) = enabled.iter().find(|p| !entry.enabled_providers.contains(p)) {
365 self.reporter.report(|| {
366 PrebuiltBinaryMessage::cache_invalidated_by_provider_change(
367 krate,
368 ProviderChangeReason::RequiredProviderNotEnabled(*missing),
369 )
370 });
371 return None;
372 }
373
374 if let ConclusiveResolution::Found(binary) = &entry.outcome {
377 if !enabled.contains(&binary.provider) {
378 self.reporter.report(|| {
379 PrebuiltBinaryMessage::cache_invalidated_by_provider_change(
380 krate,
381 ProviderChangeReason::SourceProviderDisabled(binary.provider),
382 )
383 });
384 return None;
385 }
386
387 if !binary.path.exists() {
390 self.reporter.report(|| {
391 PrebuiltBinaryMessage::cache_invalidated_by_missing_binary(krate, &binary.path)
392 });
393 warn!(
394 "Cached binary resolution for {}@{} points to missing file {:?}; ignoring cache entry",
395 krate.name, krate.version, binary.path
396 );
397 return None;
398 }
399 }
400
401 match &entry.outcome {
402 ConclusiveResolution::Found(binary) => self
403 .reporter
404 .report(|| PrebuiltBinaryMessage::positive_cache_hit(krate, &binary.path, binary.provider)),
405 ConclusiveResolution::Nonexistent => self
406 .reporter
407 .report(|| PrebuiltBinaryMessage::negative_cache_hit(krate)),
408 }
409
410 Some(entry.outcome)
411 }
412
413 fn resolve_via_providers(
416 &self,
417 krate: &DownloadedCrate,
418 target: &TargetTriple,
419 ) -> Result<BinaryResolution> {
420 if self.providers.is_empty() {
421 return error::NoProvidersConfiguredSnafu.fail();
422 }
423
424 let resolved = &krate.resolved;
425 let mut results = Vec::with_capacity(self.providers.len());
426 for provider in &self.providers {
427 let provider_kind = provider.kind();
428 self.reporter
429 .report(|| PrebuiltBinaryMessage::checking_provider(resolved, provider_kind));
430
431 let resolution = match provider.try_resolve(krate, target) {
443 Ok(resolution) => BinaryResolution::from(resolution),
444 Err(source) => {
445 self.reporter
446 .report(|| PrebuiltBinaryMessage::provider_failed(provider_kind, source.to_string()));
447 BinaryResolution::Inconclusive {
448 source: Box::new(source),
449 }
450 }
451 };
452 let found = matches!(resolution, BinaryResolution::Found(_));
453 results.push(resolution);
454 if found {
455 break;
456 }
457 }
458
459 Ok(Self::combine_resolutions(results))
460 }
461
462 fn relocate_to_bin_dir(
470 &self,
471 mut binary: ResolvedBinary,
472 krate: &ResolvedCrate,
473 target: &TargetTriple,
474 ) -> Result<ResolvedBinary> {
475 let target_dir = self.cache.crate_bin_root(krate).join(format!(
476 "prebuilt-{:?}-{}",
477 binary.provider,
478 target.as_str()
479 ));
480
481 std::fs::create_dir_all(&target_dir).with_context(|_| error::IoSnafu {
482 path: target_dir.clone(),
483 })?;
484
485 let binary_name = binary.path.file_name().ok_or_else(|| Error::Io {
486 path: binary.path.clone(),
487 source: std::io::Error::new(std::io::ErrorKind::InvalidInput, "binary path has no filename"),
488 })?;
489
490 let target_path = target_dir.join(binary_name);
491
492 std::fs::rename(&binary.path, &target_path).with_context(|_| error::RenameFileSnafu {
493 src: binary.path.clone(),
494 dst: target_path.clone(),
495 })?;
496
497 #[cfg(unix)]
498 {
499 let mut perms = std::fs::metadata(&target_path)
500 .with_context(|_| error::IoSnafu {
501 path: target_path.clone(),
502 })?
503 .permissions();
504 perms.set_mode(0o755);
505 std::fs::set_permissions(&target_path, perms).with_context(|_| error::IoSnafu {
506 path: target_path.clone(),
507 })?;
508 }
509
510 binary.path = target_path;
511 Ok(binary)
512 }
513}
514
515impl BinaryResolver for DefaultBinaryResolver {
516 fn resolve(
517 &self,
518 krate: &DownloadedCrate,
519 build_options: &BuildOptions,
520 ) -> Result<Option<ResolvedBinary>> {
521 let resolved_krate = &krate.resolved;
522
523 tracing::debug!(
524 "BinaryResolver::resolve called for {}@{}",
525 resolved_krate.name,
526 resolved_krate.version
527 );
528
529 if self.mode == UsePrebuiltBinaries::Never {
530 self.reporter
531 .report(PrebuiltBinaryMessage::prebuilt_binaries_disabled);
532 return Ok(None);
533 }
534
535 if let Some(reason) = Self::is_disqualified(build_options) {
540 if self.mode == UsePrebuiltBinaries::Always {
541 return error::PrebuiltBinaryDisqualifiedSnafu {
542 name: resolved_krate.name.clone(),
543 version: resolved_krate.version.to_string(),
544 reason,
545 }
546 .fail();
547 }
548 self.reporter
549 .report(|| PrebuiltBinaryMessage::disqualified_due_to_customization(reason));
550 return Ok(None);
551 }
552
553 if !self.config.refresh {
557 if let Some(cached) = self.get_cached_resolution(resolved_krate) {
558 let resolution = match cached {
559 ConclusiveResolution::Found(binary) => BinaryResolution::Found(binary),
560 ConclusiveResolution::Nonexistent => {
561 self.reporter.report(|| {
564 PrebuiltBinaryMessage::no_binary_found(
565 resolved_krate,
566 vec!["negative cache hit - no binary available".to_string()],
567 )
568 });
569 BinaryResolution::Nonexistent
570 }
571 };
572 return Self::apply_mode(resolution, self.mode, resolved_krate);
573 }
574 }
575
576 let target = TargetTriple::host();
579
580 let resolution = self.resolve_via_providers(krate, target)?;
581
582 let resolution = match resolution {
586 BinaryResolution::Found(binary) => {
587 let relocated = self.relocate_to_bin_dir(binary, resolved_krate, target)?;
588 self.reporter
589 .report(|| PrebuiltBinaryMessage::resolved(&relocated));
590 BinaryResolution::Found(relocated)
591 }
592 BinaryResolution::Nonexistent => {
593 self.reporter.report(|| {
594 PrebuiltBinaryMessage::no_binary_found(
595 resolved_krate,
596 vec!["no binary found from any configured provider".to_string()],
597 )
598 });
599 BinaryResolution::Nonexistent
600 }
601 BinaryResolution::Inconclusive { source } => {
602 self.reporter
603 .report(|| PrebuiltBinaryMessage::resolution_inconclusive(source.to_string()));
604 BinaryResolution::Inconclusive { source }
605 }
606 };
607
608 if let Some(outcome) = resolution.to_cacheable() {
612 let entry = BinaryCacheEntry {
613 outcome,
614 enabled_providers: self.config.prebuilt_binaries.binary_providers.clone(),
615 };
616 self.cache.put_cached_binary_resolution(resolved_krate, entry)?;
617 }
618
619 Self::apply_mode(resolution, self.mode, resolved_krate)
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use std::{
626 path::PathBuf,
627 sync::{
628 Arc,
629 atomic::{AtomicUsize, Ordering},
630 },
631 };
632
633 use assert_matches::assert_matches;
634 use semver::Version;
635 use tempfile::TempDir;
636
637 use super::*;
638 use crate::{
639 builder::{BuildOptions, BuildTarget},
640 crate_resolver::ResolvedSource,
641 };
642
643 #[expect(
645 clippy::large_enum_variant,
646 reason = "test stub; at most one instance exists per test, so the size disparity is irrelevant"
647 )]
648 enum StubOutcome {
649 Found(ResolvedBinary),
650 Nonexistent,
651 Error,
652 }
653
654 struct StubProvider {
657 outcome: StubOutcome,
658 calls: Arc<AtomicUsize>,
659 }
660
661 impl StubProvider {
662 fn found(provider: BinaryProvider, path: PathBuf) -> Self {
665 Self {
666 outcome: StubOutcome::Found(ResolvedBinary {
667 krate: test_downloaded_crate().resolved,
668 provider,
669 path,
670 target: build_context::TARGET.to_string(),
671 }),
672 calls: Arc::new(AtomicUsize::new(0)),
673 }
674 }
675
676 fn nonexistent() -> Self {
677 Self {
678 outcome: StubOutcome::Nonexistent,
679 calls: Arc::new(AtomicUsize::new(0)),
680 }
681 }
682
683 fn error() -> Self {
684 Self {
685 outcome: StubOutcome::Error,
686 calls: Arc::new(AtomicUsize::new(0)),
687 }
688 }
689 }
690
691 impl Provider for StubProvider {
692 fn kind(&self) -> BinaryProvider {
693 BinaryProvider::GithubReleases
694 }
695
696 fn try_resolve(
697 &self,
698 _krate: &DownloadedCrate,
699 _target: &TargetTriple,
700 ) -> Result<ConclusiveResolution> {
701 self.calls.fetch_add(1, Ordering::SeqCst);
702 match &self.outcome {
703 StubOutcome::Found(binary) => Ok(ConclusiveResolution::Found(binary.clone())),
704 StubOutcome::Nonexistent => Ok(ConclusiveResolution::Nonexistent),
705 StubOutcome::Error => Err(transient_error()),
706 }
707 }
708 }
709
710 fn transient_error() -> Error {
712 error::HttpStatusSnafu {
713 url: "https://api.github.com/repos/x/y/releases/tags/v1.0.0".to_string(),
714 status: 429u16,
715 }
716 .build()
717 }
718
719 fn boxed_transient() -> Box<Error> {
720 Box::new(transient_error())
721 }
722
723 fn test_env() -> (Cache, Config, TempDir) {
724 crate::logging::init_test_logging();
725
726 let (temp_dir, config) = crate::config::create_test_env();
727 let cache = Cache::new(config.clone(), MessageReporter::null());
728 (cache, config, temp_dir)
729 }
730
731 fn resolver_with(
734 cache: Cache,
735 config: Config,
736 mode: UsePrebuiltBinaries,
737 outcome: StubOutcome,
738 ) -> (DefaultBinaryResolver, Arc<AtomicUsize>) {
739 let calls = Arc::new(AtomicUsize::new(0));
740 let mut config = config;
741 config.prebuilt_binaries.use_prebuilt_binaries = mode;
742 let providers: Vec<Box<dyn Provider + Send + Sync>> = vec![Box::new(StubProvider {
743 outcome,
744 calls: calls.clone(),
745 })];
746 let staging = DefaultBinaryResolver::create_staging_dir(&config).unwrap();
747 (
748 DefaultBinaryResolver::with_providers(config, cache, MessageReporter::null(), staging, providers),
749 calls,
750 )
751 }
752
753 fn resolver_with_enabled_providers(
757 cache: Cache,
758 config: Config,
759 enabled: Vec<BinaryProvider>,
760 providers: Vec<Box<dyn Provider + Send + Sync>>,
761 ) -> DefaultBinaryResolver {
762 let mut config = config;
763 config.prebuilt_binaries.use_prebuilt_binaries = UsePrebuiltBinaries::Auto;
764 config.prebuilt_binaries.binary_providers = enabled;
765 let staging = DefaultBinaryResolver::create_staging_dir(&config).unwrap();
766 DefaultBinaryResolver::with_providers(config, cache, MessageReporter::null(), staging, providers)
767 }
768
769 fn test_downloaded_crate() -> DownloadedCrate {
770 DownloadedCrate {
771 resolved: ResolvedCrate {
772 name: "serde".to_string(),
773 version: Version::parse("1.0.0").unwrap(),
774 source: ResolvedSource::CratesIo,
775 },
776 crate_path: PathBuf::from("/nonexistent"),
777 }
778 }
779
780 fn test_resolved_binary() -> ResolvedBinary {
781 ResolvedBinary {
782 krate: test_downloaded_crate().resolved,
783 provider: BinaryProvider::GithubReleases,
784 path: PathBuf::from("/fake/bin/serde"),
785 target: build_context::TARGET.to_string(),
786 }
787 }
788
789 #[test]
791 fn test_disqualification_default_options_ok() {
792 let options = BuildOptions::default();
793 assert_eq!(DefaultBinaryResolver::is_disqualified(&options), None);
794 }
795
796 #[test]
798 fn test_disqualification_explicit_bin() {
799 let options = BuildOptions {
800 build_target: BuildTarget::Bin("specific-bin".to_string()),
801 ..Default::default()
802 };
803 assert_eq!(
804 DefaultBinaryResolver::is_disqualified(&options),
805 Some("explicit --bin or --example specified")
806 );
807 }
808
809 #[test]
811 fn test_disqualification_explicit_example() {
812 let options = BuildOptions {
813 build_target: BuildTarget::Example("my-example".to_string()),
814 ..Default::default()
815 };
816 assert_eq!(
817 DefaultBinaryResolver::is_disqualified(&options),
818 Some("explicit --bin or --example specified")
819 );
820 }
821
822 #[test]
824 fn test_disqualification_custom_features() {
825 let options = BuildOptions {
826 features: vec!["serde".to_string(), "json".to_string()],
827 ..Default::default()
828 };
829 assert_eq!(
830 DefaultBinaryResolver::is_disqualified(&options),
831 Some("custom features specified")
832 );
833 }
834
835 #[test]
837 fn test_disqualification_all_features() {
838 let options = BuildOptions {
839 all_features: true,
840 ..Default::default()
841 };
842 assert_eq!(
843 DefaultBinaryResolver::is_disqualified(&options),
844 Some("--all-features specified")
845 );
846 }
847
848 #[test]
850 fn test_disqualification_no_default_features() {
851 let options = BuildOptions {
852 no_default_features: true,
853 ..Default::default()
854 };
855 assert_eq!(
856 DefaultBinaryResolver::is_disqualified(&options),
857 Some("--no-default-features specified")
858 );
859 }
860
861 #[test]
863 fn test_disqualification_custom_profile() {
864 let options = BuildOptions {
865 profile: Some("release-with-debug".to_string()),
866 ..Default::default()
867 };
868 assert_eq!(
869 DefaultBinaryResolver::is_disqualified(&options),
870 Some("custom profile specified")
871 );
872 }
873
874 #[test]
876 fn test_disqualification_custom_target() {
877 let options = BuildOptions {
878 target: Some(TargetTriple::from_static("x86_64-unknown-linux-musl")),
879 ..Default::default()
880 };
881 assert_eq!(
882 DefaultBinaryResolver::is_disqualified(&options),
883 Some("custom target specified")
884 );
885 }
886
887 #[test]
889 fn test_disqualification_custom_toolchain() {
890 let options = BuildOptions {
891 toolchain: Some("nightly".to_string()),
892 ..Default::default()
893 };
894 assert_eq!(
895 DefaultBinaryResolver::is_disqualified(&options),
896 Some("custom toolchain specified")
897 );
898 }
899 #[test]
900 fn combine_empty_is_nonexistent() {
901 assert_matches!(
902 DefaultBinaryResolver::combine_resolutions(Vec::<BinaryResolution>::new()),
903 BinaryResolution::Nonexistent
904 );
905 }
906
907 #[test]
908 fn combine_all_nonexistent_is_nonexistent() {
909 let combined = DefaultBinaryResolver::combine_resolutions([
910 BinaryResolution::Nonexistent,
911 BinaryResolution::Nonexistent,
912 ]);
913 assert_matches!(combined, BinaryResolution::Nonexistent);
914 }
915
916 #[test]
917 fn combine_any_found_wins() {
918 let combined = DefaultBinaryResolver::combine_resolutions([
919 BinaryResolution::Inconclusive {
920 source: boxed_transient(),
921 },
922 BinaryResolution::Found(test_resolved_binary()),
923 BinaryResolution::Nonexistent,
924 ]);
925 assert_matches!(combined, BinaryResolution::Found(_));
926 }
927
928 #[test]
929 fn combine_inconclusive_beats_nonexistent() {
930 let combined = DefaultBinaryResolver::combine_resolutions([
931 BinaryResolution::Nonexistent,
932 BinaryResolution::Inconclusive {
933 source: boxed_transient(),
934 },
935 BinaryResolution::Nonexistent,
936 ]);
937 assert_matches!(combined, BinaryResolution::Inconclusive { .. });
938 }
939
940 #[test]
941 fn cacheable_found_and_nonexistent_but_never_inconclusive() {
942 assert_matches!(
943 BinaryResolution::Found(test_resolved_binary()).to_cacheable(),
944 Some(ConclusiveResolution::Found(_))
945 );
946 assert_matches!(
947 BinaryResolution::Nonexistent.to_cacheable(),
948 Some(ConclusiveResolution::Nonexistent)
949 );
950 assert_matches!(
951 BinaryResolution::Inconclusive {
952 source: boxed_transient()
953 }
954 .to_cacheable(),
955 None
956 );
957 }
958
959 #[test]
960 fn apply_mode_found_returns_binary_in_any_mode() {
961 let resolved = test_downloaded_crate().resolved;
962 for mode in [UsePrebuiltBinaries::Auto, UsePrebuiltBinaries::Always] {
963 let out = DefaultBinaryResolver::apply_mode(
964 BinaryResolution::Found(test_resolved_binary()),
965 mode,
966 &resolved,
967 )
968 .unwrap();
969 assert_matches!(out, Some(_));
970 }
971 }
972
973 #[test]
974 fn apply_mode_nonexistent_is_none_in_auto_but_errors_in_always() {
975 let resolved = test_downloaded_crate().resolved;
976 assert_matches!(
977 DefaultBinaryResolver::apply_mode(
978 BinaryResolution::Nonexistent,
979 UsePrebuiltBinaries::Auto,
980 &resolved
981 ),
982 Ok(None)
983 );
984 assert_matches!(
985 DefaultBinaryResolver::apply_mode(
986 BinaryResolution::Nonexistent,
987 UsePrebuiltBinaries::Always,
988 &resolved
989 ),
990 Err(Error::PrebuiltBinaryRequired { .. })
991 );
992 }
993
994 #[test]
995 fn apply_mode_inconclusive_is_none_in_auto_but_errors_with_source_in_always() {
996 let resolved = test_downloaded_crate().resolved;
997 assert_matches!(
998 DefaultBinaryResolver::apply_mode(
999 BinaryResolution::Inconclusive {
1000 source: boxed_transient()
1001 },
1002 UsePrebuiltBinaries::Auto,
1003 &resolved
1004 ),
1005 Ok(None)
1006 );
1007 let err = DefaultBinaryResolver::apply_mode(
1008 BinaryResolution::Inconclusive {
1009 source: boxed_transient(),
1010 },
1011 UsePrebuiltBinaries::Always,
1012 &resolved,
1013 )
1014 .unwrap_err();
1015 assert_matches!(
1016 err,
1017 Error::PrebuiltBinaryResolutionFailed { ref name, .. } if name == "serde"
1018 );
1019 }
1020
1021 #[test]
1025 fn always_mode_rejects_disqualifying_build_options() {
1026 let (cache, config, _temp) = test_env();
1027 let (resolver, calls) = resolver_with(
1028 cache,
1029 config,
1030 UsePrebuiltBinaries::Always,
1031 StubOutcome::Nonexistent,
1032 );
1033 let options = BuildOptions {
1034 profile: Some("dev".to_string()),
1035 ..Default::default()
1036 };
1037
1038 let result = resolver.resolve(&test_downloaded_crate(), &options);
1039
1040 assert_matches!(
1041 result,
1042 Err(Error::PrebuiltBinaryDisqualified { ref name, ref reason, .. })
1043 if name == "serde" && reason.contains("profile")
1044 );
1045 assert_eq!(calls.load(Ordering::SeqCst), 0);
1046 }
1047
1048 #[test]
1051 fn auto_mode_skips_prebuilt_for_disqualifying_build_options() {
1052 let (cache, config, _temp) = test_env();
1053 let (resolver, calls) =
1054 resolver_with(cache, config, UsePrebuiltBinaries::Auto, StubOutcome::Nonexistent);
1055 let options = BuildOptions {
1056 profile: Some("dev".to_string()),
1057 ..Default::default()
1058 };
1059
1060 let result = resolver.resolve(&test_downloaded_crate(), &options).unwrap();
1061
1062 assert_eq!(result, None);
1063 assert_eq!(calls.load(Ordering::SeqCst), 0);
1064 }
1065
1066 #[test]
1069 fn always_mode_errors_when_no_provider_has_binary() {
1070 let (cache, config, _temp) = test_env();
1071 let (resolver, calls) = resolver_with(
1072 cache,
1073 config,
1074 UsePrebuiltBinaries::Always,
1075 StubOutcome::Nonexistent,
1076 );
1077
1078 let result = resolver.resolve(&test_downloaded_crate(), &BuildOptions::default());
1079
1080 assert_matches!(
1081 result,
1082 Err(Error::PrebuiltBinaryRequired { ref name, .. }) if name == "serde"
1083 );
1084 assert_eq!(calls.load(Ordering::SeqCst), 1);
1085 }
1086
1087 #[test]
1091 fn always_mode_errors_on_cached_negative_result() {
1092 let (cache, config, _temp) = test_env();
1093
1094 let (auto_resolver, auto_calls) = resolver_with(
1095 cache.clone(),
1096 config.clone(),
1097 UsePrebuiltBinaries::Auto,
1098 StubOutcome::Nonexistent,
1099 );
1100 assert_matches!(
1101 auto_resolver.resolve(&test_downloaded_crate(), &BuildOptions::default()),
1102 Ok(None)
1103 );
1104 assert_eq!(auto_calls.load(Ordering::SeqCst), 1);
1105
1106 let (always_resolver, always_calls) = resolver_with(
1107 cache,
1108 config,
1109 UsePrebuiltBinaries::Always,
1110 StubOutcome::Nonexistent,
1111 );
1112 let result = always_resolver.resolve(&test_downloaded_crate(), &BuildOptions::default());
1113
1114 assert_matches!(result, Err(Error::PrebuiltBinaryRequired { .. }));
1115 assert_eq!(always_calls.load(Ordering::SeqCst), 0);
1116 }
1117
1118 #[test]
1120 fn never_mode_returns_none_without_consulting_providers() {
1121 let (cache, config, temp) = test_env();
1122 let src = temp.path().join("serde");
1123 std::fs::write(&src, b"binary").unwrap();
1124 let binary = ResolvedBinary {
1125 krate: test_downloaded_crate().resolved,
1126 provider: BinaryProvider::GithubReleases,
1127 path: src,
1128 target: build_context::TARGET.to_string(),
1129 };
1130 let (resolver, calls) = resolver_with(
1131 cache,
1132 config,
1133 UsePrebuiltBinaries::Never,
1134 StubOutcome::Found(binary),
1135 );
1136
1137 let result = resolver
1138 .resolve(&test_downloaded_crate(), &BuildOptions::default())
1139 .unwrap();
1140
1141 assert_eq!(result, None);
1142 assert_eq!(calls.load(Ordering::SeqCst), 0);
1143 }
1144
1145 #[test]
1147 fn resolved_binary_relocated_and_returned_in_always_mode() {
1148 let (cache, config, temp) = test_env();
1149 let bin_dir = config.bin_dir.clone();
1150 let src = temp.path().join("serde");
1151 std::fs::write(&src, b"binary").unwrap();
1152 let binary = ResolvedBinary {
1153 krate: test_downloaded_crate().resolved,
1154 provider: BinaryProvider::GithubReleases,
1155 path: src.clone(),
1156 target: build_context::TARGET.to_string(),
1157 };
1158 let (resolver, _calls) = resolver_with(
1159 cache,
1160 config,
1161 UsePrebuiltBinaries::Always,
1162 StubOutcome::Found(binary),
1163 );
1164
1165 let result = resolver
1166 .resolve(&test_downloaded_crate(), &BuildOptions::default())
1167 .unwrap()
1168 .unwrap();
1169
1170 assert_eq!(result.provider, BinaryProvider::GithubReleases);
1171 assert!(result.path.exists(), "relocated binary should exist");
1172 assert!(
1173 result.path.starts_with(&bin_dir),
1174 "binary should be relocated under bin_dir"
1175 );
1176 assert_ne!(result.path, src);
1177 }
1178
1179 #[test]
1182 fn inconclusive_result_is_not_cached() {
1183 let (cache, config, _temp) = test_env();
1184 let (resolver, calls) = resolver_with(
1185 cache.clone(),
1186 config,
1187 UsePrebuiltBinaries::Auto,
1188 StubOutcome::Error,
1189 );
1190
1191 let result = resolver
1192 .resolve(&test_downloaded_crate(), &BuildOptions::default())
1193 .unwrap();
1194
1195 assert_eq!(result, None);
1196 assert_eq!(calls.load(Ordering::SeqCst), 1);
1197 assert_matches!(
1198 cache.get_cached_binary_resolution(&test_downloaded_crate().resolved),
1199 Ok(None),
1200 "an inconclusive resolution must not be persisted"
1201 );
1202 }
1203
1204 #[test]
1205 fn auto_mode_continues_after_provider_error_and_returns_later_found() {
1206 let (cache, config, temp) = test_env();
1207 let src = temp.path().join("serde");
1208 std::fs::write(&src, b"binary").unwrap();
1209
1210 let first = StubProvider::error();
1211 let first_calls = first.calls.clone();
1212 let second = StubProvider::found(BinaryProvider::GithubReleases, src);
1213 let second_calls = second.calls.clone();
1214 let resolver = resolver_with_enabled_providers(
1215 cache,
1216 config,
1217 vec![BinaryProvider::GitlabReleases, BinaryProvider::GithubReleases],
1218 vec![Box::new(first), Box::new(second)],
1219 );
1220
1221 let result = resolver
1222 .resolve(&test_downloaded_crate(), &BuildOptions::default())
1223 .unwrap()
1224 .unwrap();
1225
1226 assert_eq!(result.provider, BinaryProvider::GithubReleases);
1227 assert_eq!(first_calls.load(Ordering::SeqCst), 1);
1228 assert_eq!(second_calls.load(Ordering::SeqCst), 1);
1229 }
1230
1231 #[test]
1233 fn nonexistent_result_is_cached() {
1234 let (cache, config, _temp) = test_env();
1235 let (resolver, _calls) = resolver_with(
1236 cache.clone(),
1237 config,
1238 UsePrebuiltBinaries::Auto,
1239 StubOutcome::Nonexistent,
1240 );
1241
1242 resolver
1243 .resolve(&test_downloaded_crate(), &BuildOptions::default())
1244 .unwrap();
1245
1246 assert_matches!(
1247 cache.get_cached_binary_resolution(&test_downloaded_crate().resolved),
1248 Ok(Some(BinaryCacheEntry {
1249 outcome: ConclusiveResolution::Nonexistent,
1250 ..
1251 }))
1252 );
1253 }
1254
1255 #[test]
1258 fn always_mode_errors_on_inconclusive_resolution() {
1259 let (cache, config, _temp) = test_env();
1260 let (resolver, calls) = resolver_with(
1261 cache.clone(),
1262 config,
1263 UsePrebuiltBinaries::Always,
1264 StubOutcome::Error,
1265 );
1266
1267 let result = resolver.resolve(&test_downloaded_crate(), &BuildOptions::default());
1268
1269 assert_matches!(
1270 result,
1271 Err(Error::PrebuiltBinaryResolutionFailed { ref name, ref source, .. })
1272 if name == "serde" && matches!(source.as_ref(), Error::HttpStatus { status: 429, .. })
1273 );
1274 assert_eq!(calls.load(Ordering::SeqCst), 1);
1275 assert_matches!(
1276 cache.get_cached_binary_resolution(&test_downloaded_crate().resolved),
1277 Ok(None)
1278 );
1279 }
1280
1281 #[test]
1284 fn negative_cache_invalidated_when_new_provider_enabled() {
1285 let (cache, config, temp) = test_env();
1286
1287 let gitlab1 = StubProvider::nonexistent();
1289 let gitlab1_calls = gitlab1.calls.clone();
1290 let r1 = resolver_with_enabled_providers(
1291 cache.clone(),
1292 config.clone(),
1293 vec![BinaryProvider::GitlabReleases],
1294 vec![Box::new(gitlab1)],
1295 );
1296 assert_matches!(
1297 r1.resolve(&test_downloaded_crate(), &BuildOptions::default()),
1298 Ok(None)
1299 );
1300 assert_eq!(gitlab1_calls.load(Ordering::SeqCst), 1);
1301
1302 let src = temp.path().join("serde");
1304 std::fs::write(&src, b"binary").unwrap();
1305 let gitlab2 = StubProvider::nonexistent();
1306 let github = StubProvider::found(BinaryProvider::GithubReleases, src);
1307 let github_calls = github.calls.clone();
1308 let r2 = resolver_with_enabled_providers(
1309 cache.clone(),
1310 config,
1311 vec![BinaryProvider::GitlabReleases, BinaryProvider::GithubReleases],
1312 vec![Box::new(gitlab2), Box::new(github)],
1313 );
1314
1315 let result = r2
1316 .resolve(&test_downloaded_crate(), &BuildOptions::default())
1317 .unwrap();
1318
1319 assert_matches!(result, Some(_));
1320 assert!(
1321 github_calls.load(Ordering::SeqCst) >= 1,
1322 "GitHub must be consulted once the stale negative entry is invalidated"
1323 );
1324 }
1325
1326 #[test]
1329 fn identical_provider_set_is_cache_hit() {
1330 let (cache, config, _temp) = test_env();
1331 let enabled = vec![BinaryProvider::GitlabReleases, BinaryProvider::GithubReleases];
1332
1333 let gl1 = StubProvider::nonexistent();
1334 let gh1 = StubProvider::nonexistent();
1335 let (gl1_calls, gh1_calls) = (gl1.calls.clone(), gh1.calls.clone());
1336 let r1 = resolver_with_enabled_providers(
1337 cache.clone(),
1338 config.clone(),
1339 enabled.clone(),
1340 vec![Box::new(gl1), Box::new(gh1)],
1341 );
1342 assert_matches!(
1343 r1.resolve(&test_downloaded_crate(), &BuildOptions::default()),
1344 Ok(None)
1345 );
1346 assert_eq!(gl1_calls.load(Ordering::SeqCst), 1);
1347 assert_eq!(gh1_calls.load(Ordering::SeqCst), 1);
1348
1349 let gl2 = StubProvider::nonexistent();
1350 let gh2 = StubProvider::nonexistent();
1351 let (gl2_calls, gh2_calls) = (gl2.calls.clone(), gh2.calls.clone());
1352 let r2 = resolver_with_enabled_providers(cache, config, enabled, vec![Box::new(gl2), Box::new(gh2)]);
1353 assert_matches!(
1354 r2.resolve(&test_downloaded_crate(), &BuildOptions::default()),
1355 Ok(None)
1356 );
1357 assert_eq!(
1358 gl2_calls.load(Ordering::SeqCst),
1359 0,
1360 "cache hit must not re-consult providers"
1361 );
1362 assert_eq!(
1363 gh2_calls.load(Ordering::SeqCst),
1364 0,
1365 "cache hit must not re-consult providers"
1366 );
1367 }
1368
1369 #[test]
1373 fn positive_cache_hit_with_deleted_binary_reresolves() {
1374 let (cache, config, temp) = test_env();
1375 let src = temp.path().join("serde");
1376 std::fs::write(&src, b"binary").unwrap();
1377
1378 let first = StubProvider::found(BinaryProvider::GithubReleases, src.clone());
1379 let first_calls = first.calls.clone();
1380 let r1 = resolver_with_enabled_providers(
1381 cache.clone(),
1382 config.clone(),
1383 vec![BinaryProvider::GithubReleases],
1384 vec![Box::new(first)],
1385 );
1386 let relocated = r1
1387 .resolve(&test_downloaded_crate(), &BuildOptions::default())
1388 .unwrap()
1389 .unwrap();
1390 assert_eq!(first_calls.load(Ordering::SeqCst), 1);
1391 assert!(relocated.path.exists());
1392
1393 std::fs::remove_file(&relocated.path).unwrap();
1394 std::fs::write(&src, b"binary").unwrap();
1397
1398 let second = StubProvider::found(BinaryProvider::GithubReleases, src);
1399 let second_calls = second.calls.clone();
1400 let r2 = resolver_with_enabled_providers(
1401 cache,
1402 config,
1403 vec![BinaryProvider::GithubReleases],
1404 vec![Box::new(second)],
1405 );
1406 let result = r2
1407 .resolve(&test_downloaded_crate(), &BuildOptions::default())
1408 .unwrap()
1409 .unwrap();
1410
1411 assert_eq!(
1412 second_calls.load(Ordering::SeqCst),
1413 1,
1414 "a positive entry pointing at a deleted binary must be re-resolved via providers"
1415 );
1416 assert!(
1417 result.path.exists(),
1418 "the re-resolved binary path must exist, got {}",
1419 result.path.display()
1420 );
1421 }
1422
1423 #[test]
1426 fn removing_non_finder_provider_keeps_positive_entry() {
1427 let (cache, config, temp) = test_env();
1428 let src = temp.path().join("serde");
1429 std::fs::write(&src, b"binary").unwrap();
1430
1431 let github = StubProvider::found(BinaryProvider::GithubReleases, src);
1433 let quick = StubProvider::nonexistent();
1434 let r1 = resolver_with_enabled_providers(
1435 cache.clone(),
1436 config.clone(),
1437 vec![BinaryProvider::GithubReleases, BinaryProvider::Quickinstall],
1438 vec![Box::new(github), Box::new(quick)],
1439 );
1440 assert_matches!(
1441 r1.resolve(&test_downloaded_crate(), &BuildOptions::default()),
1442 Ok(Some(_))
1443 );
1444
1445 let github2 = StubProvider::nonexistent();
1447 let github2_calls = github2.calls.clone();
1448 let r2 = resolver_with_enabled_providers(
1449 cache,
1450 config,
1451 vec![BinaryProvider::GithubReleases],
1452 vec![Box::new(github2)],
1453 );
1454 let result = r2
1455 .resolve(&test_downloaded_crate(), &BuildOptions::default())
1456 .unwrap();
1457 assert_matches!(result, Some(_));
1458 assert_eq!(
1459 github2_calls.load(Ordering::SeqCst),
1460 0,
1461 "a still-valid positive entry must not re-consult providers"
1462 );
1463 }
1464
1465 #[test]
1468 fn disabling_finder_invalidates_positive_entry() {
1469 let (cache, config, temp) = test_env();
1470 let src = temp.path().join("serde");
1471 std::fs::write(&src, b"binary").unwrap();
1472
1473 let gitlab = StubProvider::nonexistent();
1475 let github = StubProvider::found(BinaryProvider::GithubReleases, src);
1476 let r1 = resolver_with_enabled_providers(
1477 cache.clone(),
1478 config.clone(),
1479 vec![BinaryProvider::GitlabReleases, BinaryProvider::GithubReleases],
1480 vec![Box::new(gitlab), Box::new(github)],
1481 );
1482 assert_matches!(
1483 r1.resolve(&test_downloaded_crate(), &BuildOptions::default()),
1484 Ok(Some(_))
1485 );
1486
1487 let gitlab2 = StubProvider::nonexistent();
1489 let gitlab2_calls = gitlab2.calls.clone();
1490 let r2 = resolver_with_enabled_providers(
1491 cache,
1492 config,
1493 vec![BinaryProvider::GitlabReleases],
1494 vec![Box::new(gitlab2)],
1495 );
1496 let result = r2
1497 .resolve(&test_downloaded_crate(), &BuildOptions::default())
1498 .unwrap();
1499 assert_eq!(
1500 result, None,
1501 "a binary from a now-disabled provider must not be served"
1502 );
1503 assert_eq!(
1504 gitlab2_calls.load(Ordering::SeqCst),
1505 1,
1506 "must re-resolve once the finder is disabled"
1507 );
1508 }
1509}