Skip to main content

nextest_runner/
update.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Self-updates for nextest.
5
6use crate::{
7    errors::{UpdateError, UpdateVersionParseError},
8    helpers::{ThemeCharacters, progress::progress_bar_style},
9};
10use camino::{Utf8Path, Utf8PathBuf};
11use indicatif::{ProgressBar, ProgressStyle};
12use mukti_metadata::{
13    DigestAlgorithm, MuktiProject, MuktiReleasesJson, ReleaseLocation, ReleaseStatus,
14};
15use owo_colors::{OwoColorize, Style};
16use self_update::{ArchiveKind, Compression, Extract};
17use semver::{Version, VersionReq};
18use serde::Deserialize;
19use sha2::{Digest, Sha256};
20#[cfg(not(any(target_arch = "riscv32", target_arch = "riscv64")))]
21use std::sync::OnceLock;
22use std::{
23    fs,
24    io::{self, BufRead, BufReader, BufWriter, Write as _},
25    str::FromStr,
26    time::Duration,
27};
28use target_spec::Platform;
29use tracing::{debug, info, warn};
30
31/// Update backend using mukti
32#[derive(Clone, Debug)]
33pub struct MuktiBackend {
34    /// The URL to download releases from
35    pub url: String,
36
37    /// The package name.
38    pub package_name: String,
39}
40
41impl MuktiBackend {
42    /// Fetch releases.
43    pub fn fetch_releases(&self, current_version: Version) -> Result<NextestReleases, UpdateError> {
44        info!(target: "nextest-runner::update", "checking for self-updates");
45        // Is the URL a file that exists on disk? If so, use that.
46        let as_path = Utf8Path::new(&self.url);
47        let releases_buf = if as_path.exists() {
48            fs::read(as_path).map_err(|error| UpdateError::ReadLocalMetadata {
49                path: as_path.to_owned(),
50                error,
51            })?
52        } else {
53            let agent = ureq_agent();
54            agent
55                .get(&self.url)
56                .call()
57                .map_err(UpdateError::Http)?
58                .into_body()
59                // The default read_to_vec() limit is 10 MiB, which the
60                // releases JSON could plausibly exceed as releases
61                // accumulate. Use a 64 MiB limit instead.
62                .into_with_config()
63                .limit(64 * 1024 * 1024)
64                .read_to_vec()
65                .map_err(UpdateError::Http)?
66        };
67
68        let mut releases_json: MuktiReleasesJson =
69            serde_json::from_slice(&releases_buf).map_err(UpdateError::ReleaseMetadataDe)?;
70
71        let project = match releases_json.projects.remove(&self.package_name) {
72            Some(project) => project,
73            None => {
74                return Err(UpdateError::MuktiProjectNotFound {
75                    not_found: self.package_name.clone(),
76                    known: releases_json.projects.keys().cloned().collect(),
77                });
78            }
79        };
80
81        NextestReleases::new(&self.package_name, project, current_version)
82    }
83}
84
85/// Release info for nextest.
86///
87/// Returned by [`MuktiBackend::fetch_releases`].
88#[derive(Clone, Debug)]
89#[non_exhaustive]
90pub struct NextestReleases {
91    /// The package name.
92    pub package_name: String,
93
94    /// The mukti project.
95    pub project: MuktiProject,
96
97    /// The currently running version.
98    pub current_version: Version,
99
100    /// The install path.
101    pub bin_install_path: Utf8PathBuf,
102}
103
104impl NextestReleases {
105    fn new(
106        package_name: &str,
107        project: MuktiProject,
108        current_version: Version,
109    ) -> Result<Self, UpdateError> {
110        let bin_install_path = std::env::current_exe()
111            .and_then(|exe| {
112                Utf8PathBuf::try_from(exe)
113                    .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
114            })
115            .map_err(UpdateError::CurrentExe)?;
116
117        Ok(Self {
118            package_name: package_name.to_owned(),
119            project,
120            current_version,
121            bin_install_path,
122        })
123    }
124
125    /// Checks for whether an update should be performed.
126    pub fn check<'a>(
127        &'a self,
128        version_req: &UpdateVersionReq,
129        force: bool,
130        bin_path_in_archive: &'a Utf8Path,
131        perform_setup_fn: impl FnOnce(&Version) -> bool,
132    ) -> Result<CheckStatus<'a>, UpdateError> {
133        let (version, version_data) = self.resolve_version(version_req)?;
134        debug!(
135            target: "nextest-runner::update",
136            "current version is {}, update version is {version}",
137            self.current_version,
138        );
139
140        if &self.current_version == version && !force {
141            return Ok(CheckStatus::AlreadyOnRequested(version.clone()));
142        }
143        if &self.current_version > version && !force {
144            return Ok(CheckStatus::DowngradeNotAllowed {
145                current_version: self.current_version.clone(),
146                requested: version.clone(),
147            });
148        }
149
150        // Look for data for this platform.
151        let triple = self.target_triple();
152        debug!(target: "nextest-runner::update", "target triple: {triple}");
153
154        let location = version_data
155            .locations
156            .iter()
157            .find(|&data| data.format == TAR_GZ_SUFFIX && data.target == triple)
158            .ok_or_else(|| {
159                let known_triples = version_data
160                    .locations
161                    .iter()
162                    .filter(|data| data.format == TAR_GZ_SUFFIX)
163                    .map(|data| data.target.clone())
164                    .collect();
165                UpdateError::NoTargetData {
166                    version: version.clone(),
167                    triple,
168                    known_triples,
169                }
170            })?;
171
172        let force_disable_setup = version_data
173            .metadata
174            .is_some_and(|metadata| metadata.force_disable_setup);
175        let perform_setup = !force_disable_setup && perform_setup_fn(version);
176
177        Ok(CheckStatus::Success(MuktiUpdateContext {
178            context: self,
179            version: version.clone(),
180            location: location.clone(),
181            bin_path_in_archive,
182            perform_setup,
183        }))
184    }
185
186    // ---
187    // Helper methods
188    // ---
189
190    fn resolve_version(
191        &self,
192        version_req: &UpdateVersionReq,
193    ) -> Result<(&Version, ReleaseVersionData), UpdateError> {
194        match version_req {
195            UpdateVersionReq::Latest => {
196                // Get the latest stable (non-prerelease) version.
197                let (version, release_data) = self
198                    .project
199                    .get_latest_matching(&VersionReq::STAR)
200                    .ok_or(UpdateError::NoStableVersion)?;
201                Ok((version, self.parse_release_data(release_data)))
202            }
203            UpdateVersionReq::Version(update_version) => self.get_version_data(update_version),
204            UpdateVersionReq::LatestPrerelease(kind) => self.get_latest_prerelease(*kind),
205        }
206    }
207
208    fn get_version_data(
209        &self,
210        version: &UpdateVersion,
211    ) -> Result<(&Version, ReleaseVersionData), UpdateError> {
212        let (version, release_data) = match version {
213            UpdateVersion::Exact(version) => {
214                self.project.get_version_data(version).ok_or_else(|| {
215                    let known = self
216                        .project
217                        .all_versions()
218                        .map(|(v, release_data)| (v.clone(), release_data.status))
219                        .collect();
220                    UpdateError::VersionNotFound {
221                        version: version.clone(),
222                        known,
223                    }
224                })?
225            }
226            UpdateVersion::Req(req) => self
227                .project
228                .get_latest_matching(req)
229                .ok_or_else(|| UpdateError::NoMatchForVersionReq { req: req.clone() })?,
230        };
231
232        Ok((version, self.parse_release_data(release_data)))
233    }
234
235    fn get_latest_prerelease(
236        &self,
237        kind: PrereleaseKind,
238    ) -> Result<(&Version, ReleaseVersionData), UpdateError> {
239        // all_versions() returns versions in descending order (most recent first).
240        for (version, release_data) in self.project.all_versions() {
241            if release_data.status == ReleaseStatus::Active && kind.matches(version) {
242                return Ok((version, self.parse_release_data(release_data)));
243            }
244        }
245
246        Err(UpdateError::NoVersionForPrereleaseKind { kind })
247    }
248
249    fn parse_release_data(
250        &self,
251        release_data: &mukti_metadata::ReleaseVersionData,
252    ) -> ReleaseVersionData {
253        // Parse the metadata into our custom format.
254        let metadata = if release_data.metadata.is_null() {
255            None
256        } else {
257            // Attempt to parse the metadata.
258            match serde_json::from_value::<NextestReleaseMetadata>(release_data.metadata.clone()) {
259                Ok(metadata) => Some(metadata),
260                Err(error) => {
261                    warn!(
262                        target: "nextest-runner::update",
263                        "failed to parse custom release metadata: {error}",
264                    );
265                    None
266                }
267            }
268        };
269
270        ReleaseVersionData {
271            release_url: release_data.release_url.clone(),
272            status: release_data.status,
273            locations: release_data.locations.clone(),
274            metadata,
275        }
276    }
277
278    fn target_triple(&self) -> String {
279        // In this case, use the build target, *not* `rustc -vV` output. This
280        // ensures that e.g. musl binary updates continue to use the musl
281        // target.
282        let current = Platform::build_target().expect("build target could not be detected");
283        let triple_str = current.triple_str();
284        if triple_str.ends_with("-apple-darwin") {
285            // Nextest builds a universal binary for Mac.
286            "universal-apple-darwin".to_owned()
287        } else {
288            triple_str.to_owned()
289        }
290    }
291}
292
293/// Like `mukti-metadata`'s `ReleaseVersionData`, except with parsed metadata.
294#[derive(Clone, Debug)]
295pub struct ReleaseVersionData {
296    /// Canonical URL for this release
297    pub release_url: String,
298
299    /// The status of a release
300    pub status: ReleaseStatus,
301
302    /// Release locations
303    pub locations: Vec<ReleaseLocation>,
304
305    /// Custom domain-specific information stored about this release.
306    pub metadata: Option<NextestReleaseMetadata>,
307}
308
309/// Nextest-specific release metadata.
310#[derive(Clone, Debug, Deserialize)]
311pub struct NextestReleaseMetadata {
312    /// Whether to force disable `cargo nextest self setup` for this version.
313    #[serde(default)]
314    pub force_disable_setup: bool,
315}
316
317/// The result of [`NextestReleases::check`].
318#[derive(Clone, Debug)]
319pub enum CheckStatus<'a> {
320    /// The current version is the same as the requested version.
321    AlreadyOnRequested(Version),
322
323    /// A downgrade was requested but wasn't allowed.
324    DowngradeNotAllowed {
325        /// The currently running version.
326        current_version: Version,
327
328        /// The requested version.
329        requested: Version,
330    },
331
332    /// All checks were performed successfully and we are ready to update.
333    Success(MuktiUpdateContext<'a>),
334}
335
336/// Display styles for update progress output.
337#[derive(Clone, Debug, Default)]
338pub struct UpdateDisplayStyles {
339    /// Style for the progress bar prefix (e.g. "Downloading").
340    pub progress_prefix: Style,
341
342    /// Theme characters for the progress bar (ASCII or Unicode).
343    pub theme_characters: ThemeCharacters,
344}
345
346impl UpdateDisplayStyles {
347    /// Creates colorized styles.
348    pub fn colorize(&mut self) {
349        self.progress_prefix = Style::new().green().bold();
350    }
351}
352
353/// Context for an update.
354///
355/// Returned as part of the `Success` variant of [`CheckStatus`].
356#[derive(Clone, Debug)]
357#[non_exhaustive]
358pub struct MuktiUpdateContext<'a> {
359    /// The `MuktiReleases` context.
360    pub context: &'a NextestReleases,
361
362    /// The version being updated to.
363    pub version: Version,
364
365    /// The target-specific release location from which the package will be downloaded.
366    pub location: ReleaseLocation,
367
368    /// The path to the binary within the archive.
369    pub bin_path_in_archive: &'a Utf8Path,
370
371    /// Whether to run `cargo nextest self setup` as part of the update.
372    pub perform_setup: bool,
373}
374
375impl MuktiUpdateContext<'_> {
376    /// Performs the update.
377    pub fn do_update(&self, styles: &UpdateDisplayStyles) -> Result<(), UpdateError> {
378        // This method is adapted from self_update's update_extended.
379
380        let tmp_dir_parent = self.context.bin_install_path.parent().ok_or_else(|| {
381            UpdateError::CurrentExe(io::Error::new(
382                io::ErrorKind::InvalidData,
383                format!(
384                    "parent directory of current exe `{}` could not be determined",
385                    self.context.bin_install_path
386                ),
387            ))
388        })?;
389        let tmp_backup_dir_prefix = format!("__{}_backup", self.context.package_name);
390        let tmp_backup_filename = tmp_backup_dir_prefix.clone();
391
392        if cfg!(windows) {
393            // Windows executables can not be removed while they are running, which prevents clean up
394            // of the temporary directory by the `tempfile` crate after we move the running executable
395            // into it during an update. We clean up any previously created temporary directories here.
396            // Ignore errors during cleanup since this is not critical for completing the update.
397            let _ = cleanup_backup_temp_directories(
398                tmp_dir_parent,
399                &tmp_backup_dir_prefix,
400                &tmp_backup_filename,
401            );
402        }
403
404        let tmp_archive_dir_prefix = format!("{}_download", self.context.package_name);
405        let tmp_archive_dir = camino_tempfile::Builder::new()
406            .prefix(&tmp_archive_dir_prefix)
407            .tempdir_in(tmp_dir_parent)
408            .map_err(|error| UpdateError::TempDirCreate {
409                location: tmp_dir_parent.to_owned(),
410                error,
411            })?;
412        let tmp_dir_path: &Utf8Path = tmp_archive_dir.path();
413        let tmp_archive_path =
414            tmp_dir_path.join(format!("{}.{TAR_GZ_SUFFIX}", self.context.package_name));
415        let tmp_archive = fs::File::create(&tmp_archive_path).map_err(|error| {
416            UpdateError::TempArchiveCreate {
417                archive_path: tmp_archive_path.clone(),
418                error,
419            }
420        })?;
421        let mut tmp_archive_buf = BufWriter::new(tmp_archive);
422
423        let agent = ureq_agent();
424        let resp = agent
425            .get(&self.location.url)
426            .header(http::header::ACCEPT.as_str(), "application/octet-stream")
427            .call()
428            .map_err(UpdateError::Http)?;
429
430        let content_length: Option<u64> =
431            match resp.headers().get(http::header::CONTENT_LENGTH.as_str()) {
432                Some(v) => {
433                    let s = v.to_str().map_err(|_| UpdateError::ContentLengthInvalid {
434                        value: format!("{v:?}"),
435                    })?;
436                    let len = s
437                        .parse::<u64>()
438                        .map_err(|_| UpdateError::ContentLengthInvalid {
439                            value: s.to_owned(),
440                        })?;
441                    Some(len)
442                }
443                None => None,
444            };
445
446        // Stream the response body to the archive file with a progress bar.
447        // into_reader() has no size limit, unlike read_to_vec(). This is
448        // intentional: release archives are ~10 MiB as of March 2026 and
449        // are streamed to a temporary file via tmp_archive_buf above,
450        // not buffered entirely in memory. The SHA-256 checksum verified
451        // later guards against corruption or truncation.
452        let mut src = BufReader::new(resp.into_body().into_reader());
453        let prefix = format!("{}", "Downloading".style(styles.progress_prefix));
454        let progress_bar = match content_length {
455            Some(size) => {
456                let pb = ProgressBar::new(size);
457                pb.set_style(progress_bar_style(
458                    styles.theme_characters.progress_chars(),
459                    "{bytes:>9}/{total_bytes:>9}",
460                ));
461                pb
462            }
463            None => {
464                let pb = ProgressBar::new_spinner();
465                pb.set_style(
466                    ProgressStyle::default_spinner()
467                        .template("{prefix:>12} [{elapsed_precise:>9}] {bytes}")
468                        .expect("valid progress spinner template"),
469                );
470                pb
471            }
472        };
473        progress_bar.set_prefix(prefix);
474
475        let mut downloaded: u64 = 0;
476        loop {
477            let n = {
478                let buf = src.fill_buf().map_err(UpdateError::HttpBody)?;
479                tmp_archive_buf
480                    .write_all(buf)
481                    .map_err(|error| UpdateError::TempArchiveWrite {
482                        archive_path: tmp_archive_path.clone(),
483                        error,
484                    })?;
485                buf.len()
486            };
487            if n == 0 {
488                break;
489            }
490            src.consume(n);
491            downloaded += n as u64;
492            progress_bar.set_position(downloaded);
493        }
494        progress_bar.finish();
495
496        // Verify that we received the expected number of bytes.
497        if let Some(expected) = content_length
498            && downloaded != expected
499        {
500            return Err(UpdateError::ContentLengthMismatch {
501                expected,
502                actual: downloaded,
503            });
504        }
505
506        debug!(target: "nextest-runner::update", "downloaded to {tmp_archive_path}");
507
508        let tmp_archive =
509            tmp_archive_buf
510                .into_inner()
511                .map_err(|error| UpdateError::TempArchiveWrite {
512                    archive_path: tmp_archive_path.clone(),
513                    error: error.into_error(),
514                })?;
515        tmp_archive
516            .sync_all()
517            .map_err(|error| UpdateError::TempArchiveWrite {
518                archive_path: tmp_archive_path.clone(),
519                error,
520            })?;
521        std::mem::drop(tmp_archive);
522
523        // Verify the checksum of the downloaded file if available.
524        let mut hasher = Sha256::default();
525        // Just read the file into memory for now -- it would be nice to have an
526        // incremental hasher that updates the hash as it's being downloaded,
527        // but it's not critical since our archives are quite small.
528        let mut tmp_archive =
529            fs::File::open(&tmp_archive_path).map_err(|error| UpdateError::TempArchiveRead {
530                archive_path: tmp_archive_path.clone(),
531                error,
532            })?;
533        io::copy(&mut tmp_archive, &mut hasher).map_err(|error| UpdateError::TempArchiveRead {
534            archive_path: tmp_archive_path.clone(),
535            error,
536        })?;
537        let hash = hasher.finalize();
538        let hash_str = hex::encode(hash);
539
540        match self.location.checksums.get(&DigestAlgorithm::SHA256) {
541            Some(checksum) => {
542                if checksum.0 != hash_str {
543                    return Err(UpdateError::ChecksumMismatch {
544                        expected: checksum.0.clone(),
545                        actual: hash_str,
546                    });
547                }
548                debug!(target: "nextest-runner::update", "SHA-256 checksum verified: {hash_str}");
549            }
550            None => {
551                warn!(target: "nextest-runner::update", "unable to verify SHA-256 checksum of downloaded archive ({hash_str})");
552            }
553        }
554
555        // Now extract data from this archive.
556        Extract::from_source(tmp_archive_path.as_std_path())
557            .archive(ArchiveKind::Tar(Some(Compression::Gz)))
558            .extract_file(
559                tmp_archive_dir.path().as_std_path(),
560                self.bin_path_in_archive,
561            )
562            .map_err(UpdateError::SelfUpdate)?;
563
564        // Since we're currently restricted to .tar.gz which carries metadata with it, there's no
565        // need to make this file executable.
566
567        let new_exe = tmp_dir_path.join(self.bin_path_in_archive);
568        debug!(target: "nextest-runner::update", "extracted to {new_exe}, replacing existing binary");
569
570        let tmp_backup_dir = camino_tempfile::Builder::new()
571            .prefix(&tmp_backup_dir_prefix)
572            .tempdir_in(tmp_dir_parent)
573            .map_err(|error| UpdateError::TempDirCreate {
574                location: tmp_dir_parent.to_owned(),
575                error,
576            })?;
577
578        let tmp_backup_dir_path: &Utf8Path = tmp_backup_dir.path();
579        let tmp_file_path = tmp_backup_dir_path.join(&tmp_backup_filename);
580
581        Move::from_source(&new_exe)
582            .replace_using_temp(&tmp_file_path)
583            .to_dest(&self.context.bin_install_path)?;
584
585        // Finally, run `cargo nextest self setup` if requested.
586        if self.perform_setup {
587            info!(target: "nextest-runner::update", "running `cargo nextest self setup`");
588            let mut cmd = std::process::Command::new(&self.context.bin_install_path);
589            cmd.args(["nextest", "self", "setup", "--source", "self-update"]);
590            let status = cmd.status().map_err(UpdateError::SelfSetup)?;
591            if !status.success() {
592                return Err(UpdateError::SelfSetup(io::Error::other(format!(
593                    "`cargo nextest self setup` failed with exit code {}",
594                    status
595                        .code()
596                        .map_or("(unknown)".to_owned(), |c| c.to_string())
597                ))));
598            }
599        }
600
601        Ok(())
602    }
603}
604
605/// Moves a file from the given path to the specified destination.
606///
607/// `source` and `dest` must be on the same filesystem.
608/// If `replace_using_temp` is specified, the destination file will be
609/// replaced using the given temporary path.
610/// If the existing `dest` file is a currently running long running program,
611/// `replace_using_temp` may run into errors cleaning up the temp dir.
612/// If that's the case for your use-case, consider not specifying a temp dir to use.
613///
614/// * Errors:
615///     * Io - copying / renaming
616#[derive(Debug)]
617struct Move<'a> {
618    source: &'a Utf8Path,
619    temp: Option<&'a Utf8Path>,
620}
621impl<'a> Move<'a> {
622    /// Specify source file
623    pub fn from_source(source: &'a Utf8Path) -> Move<'a> {
624        Self { source, temp: None }
625    }
626
627    /// If specified and the destination file already exists, the "destination"
628    /// file will be moved to the given temporary location before the "source"
629    /// file is moved to the "destination" file.
630    ///
631    /// In the event of an `io` error while renaming "source" to "destination",
632    /// the temporary file will be moved back to "destination".
633    ///
634    /// The `temp` dir must be explicitly provided since `rename` operations require
635    /// files to live on the same filesystem.
636    pub fn replace_using_temp(&mut self, temp: &'a Utf8Path) -> &mut Self {
637        self.temp = Some(temp);
638        self
639    }
640
641    /// Move source file to specified destination
642    pub fn to_dest(&self, dest: &Utf8Path) -> Result<(), UpdateError> {
643        match self.temp {
644            None => Self::fs_rename(self.source, dest),
645            Some(temp) => {
646                if dest.exists() {
647                    // Move the existing dest to a temp location so we can move it
648                    // back it there's an error. If the existing `dest` file is a
649                    // long running program, this may prevent the temp dir from
650                    // being cleaned up.
651                    Self::fs_rename(dest, temp)?;
652                    if let Err(e) = Self::fs_rename(self.source, dest) {
653                        Self::fs_rename(temp, dest)?;
654                        return Err(e);
655                    }
656                } else {
657                    Self::fs_rename(self.source, dest)?;
658                }
659                Ok(())
660            }
661        }
662    }
663
664    // ---
665    // Helper methods
666    // ---
667
668    fn fs_rename(source: &Utf8Path, dest: &Utf8Path) -> Result<(), UpdateError> {
669        fs::rename(source, dest).map_err(|error| UpdateError::FsRename {
670            source: source.to_owned(),
671            dest: dest.to_owned(),
672            error,
673        })
674    }
675}
676
677fn cleanup_backup_temp_directories(
678    tmp_dir_parent: &Utf8Path,
679    tmp_dir_prefix: &str,
680    expected_tmp_filename: &str,
681) -> io::Result<()> {
682    for entry in fs::read_dir(tmp_dir_parent)? {
683        let entry = entry?;
684        let tmp_dir_name = if let Ok(tmp_dir_name) = entry.file_name().into_string() {
685            tmp_dir_name
686        } else {
687            continue;
688        };
689
690        // For safety, check that the temporary directory contains only the expected backup
691        // binary file before removing. If subdirectories or other files exist then the user
692        // is using the temp directory for something else. This is unlikely, but we should
693        // be careful with `fs::remove_dir_all`.
694        let is_expected_tmp_file = |tmp_file_entry: std::io::Result<fs::DirEntry>| {
695            tmp_file_entry
696                .ok()
697                .filter(|e| e.file_name() == expected_tmp_filename)
698                .is_some()
699        };
700
701        if tmp_dir_name.starts_with(tmp_dir_prefix)
702            && fs::read_dir(entry.path())?.all(is_expected_tmp_file)
703        {
704            fs::remove_dir_all(entry.path())?;
705        }
706    }
707    Ok(())
708}
709
710/// Returns a ureq agent configured with rustls and the aws-lc-rs crypto
711/// provider.
712///
713/// On RISC-V, rustls/aws-lc-rs aren't available; see the RISC-V variant
714/// below.
715#[cfg(not(any(target_arch = "riscv32", target_arch = "riscv64")))]
716fn ureq_agent() -> ureq::Agent {
717    // Install aws-lc-rs as the default rustls crypto provider. The OnceLock
718    // ensures we only attempt this once; if it's already set (by us or someone
719    // else), we log and move on.
720    static PROVIDER_INSTALLED: OnceLock<()> = OnceLock::new();
721    PROVIDER_INSTALLED.get_or_init(|| {
722        if rustls::crypto::CryptoProvider::install_default(
723            rustls::crypto::aws_lc_rs::default_provider(),
724        )
725        .is_err()
726        {
727            warn!(
728                target: "nextest-runner::update",
729                "a rustls crypto provider was already installed; \
730                 using the existing provider",
731            );
732        }
733    });
734
735    ureq::Agent::new_with_config(
736        ureq::Agent::config_builder()
737            // Set a connect timeout to avoid hanging on unreachable hosts.
738            // No global timeout: binary downloads can be large on slow
739            // connections, and the progress bar keeps the user informed.
740            .timeout_connect(Some(CONNECT_TIMEOUT))
741            .tls_config(
742                ureq::tls::TlsConfig::builder()
743                    .provider(ureq::tls::TlsProvider::Rustls)
744                    .build(),
745            )
746            .build(),
747    )
748}
749
750/// Returns a ureq agent configured with native-tls.
751///
752/// On RISC-V, rustls/aws-lc-rs aren't available (see
753/// <https://github.com/nextest-rs/nextest/issues/820>), so native-tls is used
754/// instead.
755#[cfg(any(target_arch = "riscv32", target_arch = "riscv64"))]
756fn ureq_agent() -> ureq::Agent {
757    ureq::Agent::new_with_config(
758        ureq::Agent::config_builder()
759            .timeout_connect(Some(CONNECT_TIMEOUT))
760            .tls_config(
761                ureq::tls::TlsConfig::builder()
762                    .provider(ureq::tls::TlsProvider::NativeTls)
763                    .build(),
764            )
765            .build(),
766    )
767}
768
769/// Connect timeout for HTTP requests. Matches reqwest's old default of 30
770/// seconds, which self_update 0.42.0 relied on implicitly.
771const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
772
773const TAR_GZ_SUFFIX: &str = "tar.gz";
774
775/// Represents the user's requested version for an update.
776#[derive(Clone, Debug, Eq, PartialEq)]
777pub enum UpdateVersionReq {
778    /// Update to the latest stable (non-prerelease) version.
779    Latest,
780
781    /// Update to a specific version or version requirement.
782    Version(UpdateVersion),
783
784    /// Update to the latest prerelease of the given kind.
785    LatestPrerelease(PrereleaseKind),
786}
787
788/// The kind of prerelease to look for.
789#[derive(Clone, Copy, Debug, Eq, PartialEq)]
790pub enum PrereleaseKind {
791    /// Beta channel (includes beta `-b.N`, RC `-rc.N`, and stable versions).
792    Beta,
793
794    /// Release candidate channel (`-rc.N` and stable versions).
795    Rc,
796}
797
798impl PrereleaseKind {
799    /// Returns true if this version is acceptable for this prerelease kind.
800    ///
801    /// `Beta` accepts stable, RC, or beta versions.
802    ///
803    /// `Rc` accepts stable or RC versions (not beta).
804    pub fn matches(&self, version: &Version) -> bool {
805        // Stable versions are always acceptable.
806        if version.pre.is_empty() {
807            return true;
808        }
809        let pre_str = version.pre.as_str();
810        match self {
811            // Beta accepts everything: stable, -b.N, or -rc.N.
812            PrereleaseKind::Beta => pre_str.starts_with("b.") || pre_str.starts_with("rc."),
813            // RC accepts stable or -rc.N (not -b.N).
814            PrereleaseKind::Rc => pre_str.starts_with("rc."),
815        }
816    }
817
818    /// Returns a description of this prerelease kind for user-facing messages.
819    pub fn description(&self) -> &'static str {
820        match self {
821            PrereleaseKind::Beta => "beta or RC",
822            PrereleaseKind::Rc => "RC",
823        }
824    }
825}
826
827/// Represents the version this project is being updated to.
828#[derive(Clone, Debug, Eq, PartialEq)]
829pub enum UpdateVersion {
830    /// Update to this exact version.
831    Exact(Version),
832
833    /// Update to the latest non-pre-release, non-yanked version matching this [`VersionReq`].
834    Req(VersionReq),
835}
836
837/// Parses x.y.z as if it were =x.y.z, and provides error messages in the case of invalid
838/// values.
839impl FromStr for UpdateVersion {
840    type Err = UpdateVersionParseError;
841
842    fn from_str(input: &str) -> Result<Self, Self::Err> {
843        // Adapted from Cargo's source:
844        // https://github.com/rust-lang/cargo/blob/6b8e1922261bbed1894bf40069fb2d5dc8d62fb0/src/cargo/ops/cargo_install.rs#L760-L806
845
846        // If the version begins with character <, >, =, ^, ~ parse it as a
847        // version range, otherwise parse it as a specific version
848
849        if input == "latest" {
850            return Ok(UpdateVersion::Req(VersionReq::STAR));
851        }
852
853        let first = input
854            .chars()
855            .next()
856            .ok_or(UpdateVersionParseError::EmptyString)?;
857
858        let is_req = "<>=^~".contains(first) || input.contains('*');
859        if is_req {
860            match input.parse::<VersionReq>() {
861                Ok(v) => Ok(Self::Req(v)),
862                Err(error) => Err(UpdateVersionParseError::InvalidVersionReq {
863                    input: input.to_owned(),
864                    error,
865                }),
866            }
867        } else {
868            match input.parse::<Version>() {
869                Ok(v) => Ok(Self::Exact(v)),
870                Err(error) => Err(UpdateVersionParseError::InvalidVersion {
871                    input: input.to_owned(),
872                    error,
873                }),
874            }
875        }
876    }
877}