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,
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(
458                    ProgressStyle::default_bar()
459                        .template(
460                            "  {prefix:>10} [{elapsed_precise:>9}] {wide_bar} \
461                             {bytes:>9}/{total_bytes:>9}",
462                        )
463                        .expect("valid progress bar template")
464                        .progress_chars(styles.theme_characters.progress_chars()),
465                );
466                pb
467            }
468            None => {
469                let pb = ProgressBar::new_spinner();
470                pb.set_style(
471                    ProgressStyle::default_spinner()
472                        .template("  {prefix:>10} [{elapsed_precise:>9}] {bytes}")
473                        .expect("valid progress spinner template"),
474                );
475                pb
476            }
477        };
478        progress_bar.set_prefix(prefix);
479
480        let mut downloaded: u64 = 0;
481        loop {
482            let n = {
483                let buf = src.fill_buf().map_err(UpdateError::HttpBody)?;
484                tmp_archive_buf
485                    .write_all(buf)
486                    .map_err(|error| UpdateError::TempArchiveWrite {
487                        archive_path: tmp_archive_path.clone(),
488                        error,
489                    })?;
490                buf.len()
491            };
492            if n == 0 {
493                break;
494            }
495            src.consume(n);
496            downloaded += n as u64;
497            progress_bar.set_position(downloaded);
498        }
499        progress_bar.finish();
500
501        // Verify that we received the expected number of bytes.
502        if let Some(expected) = content_length
503            && downloaded != expected
504        {
505            return Err(UpdateError::ContentLengthMismatch {
506                expected,
507                actual: downloaded,
508            });
509        }
510
511        debug!(target: "nextest-runner::update", "downloaded to {tmp_archive_path}");
512
513        let tmp_archive =
514            tmp_archive_buf
515                .into_inner()
516                .map_err(|error| UpdateError::TempArchiveWrite {
517                    archive_path: tmp_archive_path.clone(),
518                    error: error.into_error(),
519                })?;
520        tmp_archive
521            .sync_all()
522            .map_err(|error| UpdateError::TempArchiveWrite {
523                archive_path: tmp_archive_path.clone(),
524                error,
525            })?;
526        std::mem::drop(tmp_archive);
527
528        // Verify the checksum of the downloaded file if available.
529        let mut hasher = Sha256::default();
530        // Just read the file into memory for now -- it would be nice to have an
531        // incremental hasher that updates the hash as it's being downloaded,
532        // but it's not critical since our archives are quite small.
533        let mut tmp_archive =
534            fs::File::open(&tmp_archive_path).map_err(|error| UpdateError::TempArchiveRead {
535                archive_path: tmp_archive_path.clone(),
536                error,
537            })?;
538        io::copy(&mut tmp_archive, &mut hasher).map_err(|error| UpdateError::TempArchiveRead {
539            archive_path: tmp_archive_path.clone(),
540            error,
541        })?;
542        let hash = hasher.finalize();
543        let hash_str = hex::encode(hash);
544
545        match self.location.checksums.get(&DigestAlgorithm::SHA256) {
546            Some(checksum) => {
547                if checksum.0 != hash_str {
548                    return Err(UpdateError::ChecksumMismatch {
549                        expected: checksum.0.clone(),
550                        actual: hash_str,
551                    });
552                }
553                debug!(target: "nextest-runner::update", "SHA-256 checksum verified: {hash_str}");
554            }
555            None => {
556                warn!(target: "nextest-runner::update", "unable to verify SHA-256 checksum of downloaded archive ({hash_str})");
557            }
558        }
559
560        // Now extract data from this archive.
561        Extract::from_source(tmp_archive_path.as_std_path())
562            .archive(ArchiveKind::Tar(Some(Compression::Gz)))
563            .extract_file(
564                tmp_archive_dir.path().as_std_path(),
565                self.bin_path_in_archive,
566            )
567            .map_err(UpdateError::SelfUpdate)?;
568
569        // Since we're currently restricted to .tar.gz which carries metadata with it, there's no
570        // need to make this file executable.
571
572        let new_exe = tmp_dir_path.join(self.bin_path_in_archive);
573        debug!(target: "nextest-runner::update", "extracted to {new_exe}, replacing existing binary");
574
575        let tmp_backup_dir = camino_tempfile::Builder::new()
576            .prefix(&tmp_backup_dir_prefix)
577            .tempdir_in(tmp_dir_parent)
578            .map_err(|error| UpdateError::TempDirCreate {
579                location: tmp_dir_parent.to_owned(),
580                error,
581            })?;
582
583        let tmp_backup_dir_path: &Utf8Path = tmp_backup_dir.path();
584        let tmp_file_path = tmp_backup_dir_path.join(&tmp_backup_filename);
585
586        Move::from_source(&new_exe)
587            .replace_using_temp(&tmp_file_path)
588            .to_dest(&self.context.bin_install_path)?;
589
590        // Finally, run `cargo nextest self setup` if requested.
591        if self.perform_setup {
592            info!(target: "nextest-runner::update", "running `cargo nextest self setup`");
593            let mut cmd = std::process::Command::new(&self.context.bin_install_path);
594            cmd.args(["nextest", "self", "setup", "--source", "self-update"]);
595            let status = cmd.status().map_err(UpdateError::SelfSetup)?;
596            if !status.success() {
597                return Err(UpdateError::SelfSetup(io::Error::other(format!(
598                    "`cargo nextest self setup` failed with exit code {}",
599                    status
600                        .code()
601                        .map_or("(unknown)".to_owned(), |c| c.to_string())
602                ))));
603            }
604        }
605
606        Ok(())
607    }
608}
609
610/// Moves a file from the given path to the specified destination.
611///
612/// `source` and `dest` must be on the same filesystem.
613/// If `replace_using_temp` is specified, the destination file will be
614/// replaced using the given temporary path.
615/// If the existing `dest` file is a currently running long running program,
616/// `replace_using_temp` may run into errors cleaning up the temp dir.
617/// If that's the case for your use-case, consider not specifying a temp dir to use.
618///
619/// * Errors:
620///     * Io - copying / renaming
621#[derive(Debug)]
622struct Move<'a> {
623    source: &'a Utf8Path,
624    temp: Option<&'a Utf8Path>,
625}
626impl<'a> Move<'a> {
627    /// Specify source file
628    pub fn from_source(source: &'a Utf8Path) -> Move<'a> {
629        Self { source, temp: None }
630    }
631
632    /// If specified and the destination file already exists, the "destination"
633    /// file will be moved to the given temporary location before the "source"
634    /// file is moved to the "destination" file.
635    ///
636    /// In the event of an `io` error while renaming "source" to "destination",
637    /// the temporary file will be moved back to "destination".
638    ///
639    /// The `temp` dir must be explicitly provided since `rename` operations require
640    /// files to live on the same filesystem.
641    pub fn replace_using_temp(&mut self, temp: &'a Utf8Path) -> &mut Self {
642        self.temp = Some(temp);
643        self
644    }
645
646    /// Move source file to specified destination
647    pub fn to_dest(&self, dest: &Utf8Path) -> Result<(), UpdateError> {
648        match self.temp {
649            None => Self::fs_rename(self.source, dest),
650            Some(temp) => {
651                if dest.exists() {
652                    // Move the existing dest to a temp location so we can move it
653                    // back it there's an error. If the existing `dest` file is a
654                    // long running program, this may prevent the temp dir from
655                    // being cleaned up.
656                    Self::fs_rename(dest, temp)?;
657                    if let Err(e) = Self::fs_rename(self.source, dest) {
658                        Self::fs_rename(temp, dest)?;
659                        return Err(e);
660                    }
661                } else {
662                    Self::fs_rename(self.source, dest)?;
663                }
664                Ok(())
665            }
666        }
667    }
668
669    // ---
670    // Helper methods
671    // ---
672
673    fn fs_rename(source: &Utf8Path, dest: &Utf8Path) -> Result<(), UpdateError> {
674        fs::rename(source, dest).map_err(|error| UpdateError::FsRename {
675            source: source.to_owned(),
676            dest: dest.to_owned(),
677            error,
678        })
679    }
680}
681
682fn cleanup_backup_temp_directories(
683    tmp_dir_parent: &Utf8Path,
684    tmp_dir_prefix: &str,
685    expected_tmp_filename: &str,
686) -> io::Result<()> {
687    for entry in fs::read_dir(tmp_dir_parent)? {
688        let entry = entry?;
689        let tmp_dir_name = if let Ok(tmp_dir_name) = entry.file_name().into_string() {
690            tmp_dir_name
691        } else {
692            continue;
693        };
694
695        // For safety, check that the temporary directory contains only the expected backup
696        // binary file before removing. If subdirectories or other files exist then the user
697        // is using the temp directory for something else. This is unlikely, but we should
698        // be careful with `fs::remove_dir_all`.
699        let is_expected_tmp_file = |tmp_file_entry: std::io::Result<fs::DirEntry>| {
700            tmp_file_entry
701                .ok()
702                .filter(|e| e.file_name() == expected_tmp_filename)
703                .is_some()
704        };
705
706        if tmp_dir_name.starts_with(tmp_dir_prefix)
707            && fs::read_dir(entry.path())?.all(is_expected_tmp_file)
708        {
709            fs::remove_dir_all(entry.path())?;
710        }
711    }
712    Ok(())
713}
714
715/// Returns a ureq agent configured with rustls and the aws-lc-rs crypto
716/// provider.
717///
718/// On RISC-V, rustls/aws-lc-rs aren't available; see the RISC-V variant
719/// below.
720#[cfg(not(any(target_arch = "riscv32", target_arch = "riscv64")))]
721fn ureq_agent() -> ureq::Agent {
722    // Install aws-lc-rs as the default rustls crypto provider. The OnceLock
723    // ensures we only attempt this once; if it's already set (by us or someone
724    // else), we log and move on.
725    static PROVIDER_INSTALLED: OnceLock<()> = OnceLock::new();
726    PROVIDER_INSTALLED.get_or_init(|| {
727        if rustls::crypto::CryptoProvider::install_default(
728            rustls::crypto::aws_lc_rs::default_provider(),
729        )
730        .is_err()
731        {
732            warn!(
733                target: "nextest-runner::update",
734                "a rustls crypto provider was already installed; \
735                 using the existing provider",
736            );
737        }
738    });
739
740    ureq::Agent::new_with_config(
741        ureq::Agent::config_builder()
742            // Set a connect timeout to avoid hanging on unreachable hosts.
743            // No global timeout: binary downloads can be large on slow
744            // connections, and the progress bar keeps the user informed.
745            .timeout_connect(Some(CONNECT_TIMEOUT))
746            .tls_config(
747                ureq::tls::TlsConfig::builder()
748                    .provider(ureq::tls::TlsProvider::Rustls)
749                    .build(),
750            )
751            .build(),
752    )
753}
754
755/// Returns a ureq agent configured with native-tls.
756///
757/// On RISC-V, rustls/aws-lc-rs aren't available (see
758/// <https://github.com/nextest-rs/nextest/issues/820>), so native-tls is used
759/// instead.
760#[cfg(any(target_arch = "riscv32", target_arch = "riscv64"))]
761fn ureq_agent() -> ureq::Agent {
762    ureq::Agent::new_with_config(
763        ureq::Agent::config_builder()
764            .timeout_connect(Some(CONNECT_TIMEOUT))
765            .tls_config(
766                ureq::tls::TlsConfig::builder()
767                    .provider(ureq::tls::TlsProvider::NativeTls)
768                    .build(),
769            )
770            .build(),
771    )
772}
773
774/// Connect timeout for HTTP requests. Matches reqwest's old default of 30
775/// seconds, which self_update 0.42.0 relied on implicitly.
776const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
777
778const TAR_GZ_SUFFIX: &str = "tar.gz";
779
780/// Represents the user's requested version for an update.
781#[derive(Clone, Debug, Eq, PartialEq)]
782pub enum UpdateVersionReq {
783    /// Update to the latest stable (non-prerelease) version.
784    Latest,
785
786    /// Update to a specific version or version requirement.
787    Version(UpdateVersion),
788
789    /// Update to the latest prerelease of the given kind.
790    LatestPrerelease(PrereleaseKind),
791}
792
793/// The kind of prerelease to look for.
794#[derive(Clone, Copy, Debug, Eq, PartialEq)]
795pub enum PrereleaseKind {
796    /// Beta channel (includes beta `-b.N`, RC `-rc.N`, and stable versions).
797    Beta,
798
799    /// Release candidate channel (`-rc.N` and stable versions).
800    Rc,
801}
802
803impl PrereleaseKind {
804    /// Returns true if this version is acceptable for this prerelease kind.
805    ///
806    /// `Beta` accepts stable, RC, or beta versions.
807    ///
808    /// `Rc` accepts stable or RC versions (not beta).
809    pub fn matches(&self, version: &Version) -> bool {
810        // Stable versions are always acceptable.
811        if version.pre.is_empty() {
812            return true;
813        }
814        let pre_str = version.pre.as_str();
815        match self {
816            // Beta accepts everything: stable, -b.N, or -rc.N.
817            PrereleaseKind::Beta => pre_str.starts_with("b.") || pre_str.starts_with("rc."),
818            // RC accepts stable or -rc.N (not -b.N).
819            PrereleaseKind::Rc => pre_str.starts_with("rc."),
820        }
821    }
822
823    /// Returns a description of this prerelease kind for user-facing messages.
824    pub fn description(&self) -> &'static str {
825        match self {
826            PrereleaseKind::Beta => "beta or RC",
827            PrereleaseKind::Rc => "RC",
828        }
829    }
830}
831
832/// Represents the version this project is being updated to.
833#[derive(Clone, Debug, Eq, PartialEq)]
834pub enum UpdateVersion {
835    /// Update to this exact version.
836    Exact(Version),
837
838    /// Update to the latest non-pre-release, non-yanked version matching this [`VersionReq`].
839    Req(VersionReq),
840}
841
842/// Parses x.y.z as if it were =x.y.z, and provides error messages in the case of invalid
843/// values.
844impl FromStr for UpdateVersion {
845    type Err = UpdateVersionParseError;
846
847    fn from_str(input: &str) -> Result<Self, Self::Err> {
848        // Adapted from Cargo's source:
849        // https://github.com/rust-lang/cargo/blob/6b8e1922261bbed1894bf40069fb2d5dc8d62fb0/src/cargo/ops/cargo_install.rs#L760-L806
850
851        // If the version begins with character <, >, =, ^, ~ parse it as a
852        // version range, otherwise parse it as a specific version
853
854        if input == "latest" {
855            return Ok(UpdateVersion::Req(VersionReq::STAR));
856        }
857
858        let first = input
859            .chars()
860            .next()
861            .ok_or(UpdateVersionParseError::EmptyString)?;
862
863        let is_req = "<>=^~".contains(first) || input.contains('*');
864        if is_req {
865            match input.parse::<VersionReq>() {
866                Ok(v) => Ok(Self::Req(v)),
867                Err(error) => Err(UpdateVersionParseError::InvalidVersionReq {
868                    input: input.to_owned(),
869                    error,
870                }),
871            }
872        } else {
873            match input.parse::<Version>() {
874                Ok(v) => Ok(Self::Exact(v)),
875                Err(error) => Err(UpdateVersionParseError::InvalidVersion {
876                    input: input.to_owned(),
877                    error,
878                }),
879            }
880        }
881    }
882}