Skip to main content

cgx_core/bin_resolver/
mod.rs

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/// A resolved binary is a pre-built executable that cgx found and prepared, so the crate can run
26/// without being built from source.
27///
28/// This type is the result of resolving a [`ResolvedCrate`].
29#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
30pub struct ResolvedBinary {
31    /// The crate for which this binary was resolved
32    pub krate: ResolvedCrate,
33
34    /// From what binary provider this binary was obtained
35    pub provider: BinaryProvider,
36
37    /// Path to the executable cgx should run
38    pub path: std::path::PathBuf,
39
40    /// The target this binary was published for.
41    ///
42    /// In most cases this is just the host's target, but there are many edge cases where a
43    /// prebuilt binary will be published for a target that doesn't match the host's target but is
44    /// compatible with the host (the most common is a musl linux binary used on a glibc linux
45    /// host).
46    pub target: String,
47}
48
49pub trait BinaryResolver {
50    /// Attempt to resolve a pre-built binary for the given crate from cache or providers.
51    ///
52    /// Returns:
53    /// - `Ok(Some(ResolvedBinary))` - Found a pre-built binary
54    /// - `Ok(None)` - No pre-built binary available (or pre-built binaries are
55    ///   disabled/disqualified, or resolution was inconclusive in a non-`always` mode)
56    /// - `Err(...)` - Resolution failed in a way that should stop execution
57    fn resolve(
58        &self,
59        krate: &DownloadedCrate,
60        build_options: &BuildOptions,
61    ) -> Result<Option<ResolvedBinary>>;
62}
63
64/// A conclusive provider outcome: a binary was found, or this provider determined
65/// no binary is available.
66///
67/// The two variants distinguish a positive result (a pre-built binary was resolved) from a
68/// conclusive negative (we determined no pre-built binary is available).  Theoretically it's
69/// possible that we could have checked at exactly the moment when a crate was just published but
70/// artifacts not yet released, or that a maintainer could go back and publish artifacts for older
71/// versions long after release, but both of these are highly unlikely.  By caching this result, we
72/// can speed up subsequent runs and avoid the many network requests (and potential throttling)
73/// that would be required to check for a pre-built binary every time.
74#[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    /// A pre-built binary was resolved.
83    Found(ResolvedBinary),
84    /// We conclusively determined that no pre-built binary is available.
85    Nonexistent,
86}
87
88/// The persisted form of a binary-resolution outcome: a [`ConclusiveResolution`] paired with the
89/// set of binary providers that were enabled when it was produced.
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub(crate) struct BinaryCacheEntry {
92    #[serde(flatten)]
93    pub(crate) outcome: ConclusiveResolution,
94    /// The binary providers that were enabled for the resolution that produced [`Self::outcome`].
95    pub(crate) enabled_providers: Vec<BinaryProvider>,
96}
97
98/// Create the default [`BinaryResolver`] implementation, respecting the given config and using the
99/// provided cache.
100pub(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    /// A pre-built binary was found and downloaded.
117    Found(ResolvedBinary),
118    /// We determined conclusively that no pre-built binary is available.
119    Nonexistent,
120    /// We could not determine whether a pre-built binary exists, because a transient error (a rate
121    /// limit, a network failure, etc) prevented a definitive answer. Behaves as "none for now"
122    /// but must never be cached.
123    Inconclusive { source: Box<Error> },
124}
125
126impl BinaryResolution {
127    /// Map this binary resolution to the [`ConclusiveResolution`] (if any) that should be written
128    /// to the binary cache to record this resolution for future runs.
129    ///
130    /// `Found`/`Nonexistent` are conclusive and cacheable; `Inconclusive` returns `None` and is
131    /// never cached. This is the core invariant that prevents a transient failure from being
132    /// persisted as a negative.
133    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
151/// The prod [`BinaryResolver`] implementation, which delegates to the configured providers and
152/// integrates with the cache to avoid repeated expensive provider calls.
153struct DefaultBinaryResolver {
154    config: Config,
155    cache: Cache,
156    reporter: MessageReporter,
157    mode: UsePrebuiltBinaries,
158    /// Staging area where the providers download and extract candidate binaries before
159    /// [`Self::relocate_to_bin_dir`] moves the winner to its durable home.
160    #[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    /// Create the resolver's staging directory on the same filesystem as `bin_dir`, so that
211    /// relocation into `bin_dir` never crosses filesystems.
212    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    /// Check if the build options disqualify the use of pre-built binaries.
243    ///
244    /// Pre-built binaries are skipped when the request changes what Cargo would build, such as
245    /// selecting features, a target, a profile, a toolchain, a bin, or an example.
246    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    /// Combine the per-provider resolutions into a single outcome.
279    ///
280    /// Precedence is `Found` > `Inconclusive` > `Nonexistent`: a found binary wins outright;
281    /// failing that, if any provider was inconclusive the overall result is inconclusive (we
282    /// cannot rule out a binary), keeping the first inconclusive error; only if every provider
283    /// conclusively reported no binary is the result `Nonexistent`. An empty iterator yields
284    /// `Nonexistent`.
285    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    /// Convert a binary resolution to the public `Option<ResolvedBinary>`, applying the
303    /// `--prebuilt-binary` mode policy.
304    ///
305    /// This will fail with appropriate errors depending on what mode is specified and what the
306    /// resolution actually was.
307    fn apply_mode(
308        resolution: BinaryResolution,
309        mode: UsePrebuiltBinaries,
310        krate: &ResolvedCrate,
311    ) -> Result<Option<ResolvedBinary>> {
312        // If mode was never, execution would not make it this far, so we don't have to handle that
313        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    /// Look up a cached binary-resolution outcome for `krate`, honoring it only if it is still
343    /// valid for the config.
344    ///
345    /// Each cached entry records the providers that were enabled when it was written. A cached
346    /// outcome is used only when:
347    ///
348    /// - every currently-enabled provider was among those recorded. A newly-enabled provider that
349    ///   wasn't accounted for could change the outcome (a negative might become a positive, or a
350    ///   higher-precedence provider might now win), so the entry is stale and we should resolve the
351    ///   binary anew with all currently enabled providers. An entry that recorded *more* providers
352    ///   than are currently enabled stays valid.
353    /// - (positive entries only) the provider that produced the cached binary is still enabled. A
354    ///   binary must never be served from a provider the user has disabled.
355    ///
356    /// Returns `None` (so resolution falls through to the providers) when there is no entry or it
357    /// is stale for the current configuration.
358    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 a provider that is enabled now was not enabled when this cache entry was written, then we
363        // consider the cache entry stale and must not use it.
364        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 the cached entry is a positive hit but it uses a provider that the user has currently
375        // disabled, then obviously we will not use that binary and the cache entry is considered stale.
376        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            // A positive entry points at a binary under `bin_dir`; if that file has been deleted
388            // since the entry was written, serving the entry would hand out a dangling path.
389            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    /// Consult each configured provider in order, short-circuiting on the first `Found`, and fold
414    /// the results with [`Self::combine_resolutions`].
415    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            // Invoke the provider, and based on the outcome construct the BinaryResolution result.
432            //
433            // If a single provide fails with any kind of error, we consider that an inconclusive
434            // result.  Under normal circumstances, providers should not be failing; if the
435            // provider determines the binary isn't found that should be an Ok response.  However,
436            // the providers do a lot of fallible things, including making API calls that can be
437            // throttled, parsing formats that could be invalid, etc.
438            //
439            // If a provider fails for a given crate input, that is likely a bug in the provider
440            // code somewhere, but such a bug should not cause the binary resolution process itself
441            // to return an error, nor should it result in a permanent negative cache entry.
442            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    /// Move a resolved binary from the transient staging area to its durable home under
463    /// `bin_dir`.
464    ///
465    /// The provider leaves the binary in the resolver's staging directory, which gets cleaned up
466    /// when the resolver is dropped; this rename is what makes the binary outlive the resolution
467    /// at a stable path under `bin_dir`. The staging temp dir is created on `bin_dir`'s
468    /// filesystem, so the rename never crosses filesystems.
469    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        // Check build-options disqualification BEFORE touching the cache: the binary cache is keyed
536        // on the resolved crate alone, so a cached binary must not be served to a build whose
537        // options disqualify the user of prebuilt binaries (such as options that enable or disable
538        // features, or otherwise require a binary that is built from source).
539        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        // Check the binary resolution cache first unless refresh mode is enabled. Only conclusive
554        // outcomes are ever stored, so a cache hit is always authoritative. The cache itself reports
555        // the lookup/hit/miss events; here we only translate a hit into a resolution outcome.
556        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                        // `cached_resolution` already reported the negative hit.  There is no reason
562                        // to try to find a prebuilt binary again.
563                        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        // Always use the build target platform for pre-built binaries. If the user overrides this by
577        // specifying a custom target, execution is not supposed to reach this point.
578        let target = TargetTriple::host();
579
580        let resolution = self.resolve_via_providers(krate, target)?;
581
582        // For a found binary, relocate it into `bin_dir` (so the cached/returned path is stable and
583        // outlives the transient staging area) and report it. Report the terminal non-found states
584        // too.
585        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        // Persist only conclusive outcomes; an inconclusive result is structurally uncacheable. The
609        // entry records the providers enabled for this resolution so a future run can tell whether the
610        // cached answer still applies (see [`Self::cached_resolution`]).
611        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    /// The canned outcome a [`StubProvider`] returns.
644    #[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    /// A [`Provider`] standing in for a real provider, returning a canned provider result and
655    /// counting how often it is consulted (so tests can assert short-circuiting and cache hits).
656    struct StubProvider {
657        outcome: StubOutcome,
658        calls: Arc<AtomicUsize>,
659    }
660
661    impl StubProvider {
662        /// A `Found` stub whose binary's `provider` (and therefore the cached finder) is
663        /// `provider`, with a real on-disk file at `path` so relocation succeeds.
664        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    /// A transient-looking HTTP 429 error, standing in for a rate limit error / network glitch.
711    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    /// Build a [`BinaryResolverImpl`] backed by a single [`StubProvider`] with the given mode and
732    /// canned outcome.
733    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    /// Build an `auto`-mode resolver whose enabled provider list (the set recorded in / checked
754    /// against the cache) is `enabled`, backed by the given stub `providers` (which only supply
755    /// outcomes; their `kind()` is irrelevant to the cache's provider-set checks).
756    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 that default build options are not disqualified
790    #[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 that explicit --bin flag disqualifies pre-built binaries
797    #[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 that explicit --example flag disqualifies pre-built binaries
810    #[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 that custom features disqualify pre-built binaries
823    #[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 that --all-features disqualifies pre-built binaries
836    #[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 that --no-default-features disqualifies pre-built binaries
849    #[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 that custom profile disqualifies pre-built binaries
862    #[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 that custom target disqualifies pre-built binaries
875    #[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 that custom toolchain disqualifies pre-built binaries
888    #[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    /// In `always` mode, build options that disqualify the use of prebuilt binaries fail the binary
1022    /// resolution because the user demanded a prebuilt binary, and yet the build options would
1023    /// force a source build. Providers are not consulted.
1024    #[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    /// In `auto` mode, build options that disquality the use of prebuilt binaries cause so the
1049    /// binary resolution to fall back to a source build.
1050    #[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    /// In `always` mode a conclusively-missing prebuilt binary is an error because we are not
1067    /// permitted to fall back to building from source.
1068    #[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    /// `always` mode applies to cached negative results too: a previous run's conclusive "no binary
1088    /// available" answer fails the invocation instead of silently building from source, and is
1089    /// served from cache without re-consulting providers.
1090    #[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    /// In `never` mode the cache and providers are not consulted at all.
1119    #[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    /// A binary a provider resolves is relocated into `bin_dir` and returned in `always` mode.
1146    #[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    /// A transient (inconclusive) resolution must NOT be cached as a negative, so a
1180    /// later run re-consults providers again.
1181    #[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    /// A conclusive absence IS cached (as a negative entry), so later runs skip the providers.
1232    #[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    /// In `always` mode an inconclusive resolution is a hard error carrying the transient source,
1256    /// and is never cached.
1257    #[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    /// The reported bug: a negative result cached when only GitLab was enabled must be re-resolved
1282    /// once GitHub is also enabled, and GitHub (which has the binary) must actually be consulted.
1283    #[test]
1284    fn negative_cache_invalidated_when_new_provider_enabled() {
1285        let (cache, config, temp) = test_env();
1286
1287        // Phase 1: only GitLab enabled, no binary -> caches Nonexistent with providers = [GitLab].
1288        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        // Phase 2: GitLab + GitHub enabled, and GitHub has a binary.
1303        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    /// An identical provider set across runs must NOT invalidate: the second run is a cache hit and
1327    /// the providers are not consulted again.
1328    #[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    /// A positive cache entry whose relocated binary has been deleted from `bin_dir` must not be
1370    /// served as a dangling path: the entry is invalidated, the providers are consulted again, and
1371    /// the returned path exists.
1372    #[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        // A real provider stages a fresh copy on every consultation; recreate the file the stub
1395        // hands out so the re-resolution has something to relocate.
1396        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    /// Removing a provider that did NOT produce the cached binary keeps the positive entry valid
1424    /// (coverage still holds and the finder is still enabled): cache hit, no re-consultation.
1425    #[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        // Phase 1: GitHub (the finder) + Quickinstall enabled; GitHub is first and is found.
1432        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        // Phase 2: drop the non-finder Quickinstall; GitHub remains enabled -> still a cache hit.
1446        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    /// Disabling the exact provider that produced the cached binary invalidates the positive entry:
1466    /// the binary must not be served, and the crate is re-resolved.
1467    #[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        // Phase 1: GitLab + GitHub enabled; GitHub (second) is found, so the finder is GitHub.
1474        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        // Phase 2: GitHub (the finder) disabled, only GitLab enabled (which has no binary).
1488        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}