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::errors::{UpdateError, UpdateVersionParseError};
7use camino::{Utf8Path, Utf8PathBuf};
8use mukti_metadata::{
9    DigestAlgorithm, MuktiProject, MuktiReleasesJson, ReleaseLocation, ReleaseStatus,
10};
11use self_update::{ArchiveKind, Compression, Download, Extract};
12use semver::{Version, VersionReq};
13use serde::Deserialize;
14use sha2::{Digest, Sha256};
15use std::{
16    fs,
17    io::{self, BufWriter},
18    str::FromStr,
19};
20use target_spec::Platform;
21use tracing::{debug, info, warn};
22
23/// Update backend using mukti
24#[derive(Clone, Debug)]
25pub struct MuktiBackend {
26    /// The URL to download releases from
27    pub url: String,
28
29    /// The package name.
30    pub package_name: String,
31}
32
33impl MuktiBackend {
34    /// Fetch releases.
35    pub fn fetch_releases(&self, current_version: Version) -> Result<NextestReleases, UpdateError> {
36        info!(target: "nextest-runner::update", "checking for self-updates");
37        // Is the URL a file that exists on disk? If so, use that.
38        let as_path = Utf8Path::new(&self.url);
39        let releases_buf = if as_path.exists() {
40            fs::read(as_path).map_err(|error| UpdateError::ReadLocalMetadata {
41                path: as_path.to_owned(),
42                error,
43            })?
44        } else {
45            let mut releases_buf: Vec<u8> = Vec::new();
46            Download::from_url(&self.url)
47                .download_to(&mut releases_buf)
48                .map_err(UpdateError::SelfUpdate)?;
49            releases_buf
50        };
51
52        let mut releases_json: MuktiReleasesJson =
53            serde_json::from_slice(&releases_buf).map_err(UpdateError::ReleaseMetadataDe)?;
54
55        let project = match releases_json.projects.remove(&self.package_name) {
56            Some(project) => project,
57            None => {
58                return Err(UpdateError::MuktiProjectNotFound {
59                    not_found: self.package_name.clone(),
60                    known: releases_json.projects.keys().cloned().collect(),
61                });
62            }
63        };
64
65        NextestReleases::new(&self.package_name, project, current_version)
66    }
67}
68
69/// Release info for nextest.
70///
71/// Returned by [`MuktiBackend::fetch_releases`].
72#[derive(Clone, Debug)]
73#[non_exhaustive]
74pub struct NextestReleases {
75    /// The package name.
76    pub package_name: String,
77
78    /// The mukti project.
79    pub project: MuktiProject,
80
81    /// The currently running version.
82    pub current_version: Version,
83
84    /// The install path.
85    pub bin_install_path: Utf8PathBuf,
86}
87
88impl NextestReleases {
89    fn new(
90        package_name: &str,
91        project: MuktiProject,
92        current_version: Version,
93    ) -> Result<Self, UpdateError> {
94        let bin_install_path = std::env::current_exe()
95            .and_then(|exe| {
96                Utf8PathBuf::try_from(exe)
97                    .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
98            })
99            .map_err(UpdateError::CurrentExe)?;
100
101        Ok(Self {
102            package_name: package_name.to_owned(),
103            project,
104            current_version,
105            bin_install_path,
106        })
107    }
108
109    /// Checks for whether an update should be performed.
110    pub fn check<'a>(
111        &'a self,
112        version_req: &UpdateVersionReq,
113        force: bool,
114        bin_path_in_archive: &'a Utf8Path,
115        perform_setup_fn: impl FnOnce(&Version) -> bool,
116    ) -> Result<CheckStatus<'a>, UpdateError> {
117        let (version, version_data) = self.resolve_version(version_req)?;
118        debug!(
119            target: "nextest-runner::update",
120            "current version is {}, update version is {version}",
121            self.current_version,
122        );
123
124        if &self.current_version == version && !force {
125            return Ok(CheckStatus::AlreadyOnRequested(version.clone()));
126        }
127        if &self.current_version > version && !force {
128            return Ok(CheckStatus::DowngradeNotAllowed {
129                current_version: self.current_version.clone(),
130                requested: version.clone(),
131            });
132        }
133
134        // Look for data for this platform.
135        let triple = self.target_triple();
136        debug!(target: "nextest-runner::update", "target triple: {triple}");
137
138        let location = version_data
139            .locations
140            .iter()
141            .find(|&data| data.format == TAR_GZ_SUFFIX && data.target == triple)
142            .ok_or_else(|| {
143                let known_triples = version_data
144                    .locations
145                    .iter()
146                    .filter(|data| data.format == TAR_GZ_SUFFIX)
147                    .map(|data| data.target.clone())
148                    .collect();
149                UpdateError::NoTargetData {
150                    version: version.clone(),
151                    triple,
152                    known_triples,
153                }
154            })?;
155
156        let force_disable_setup = version_data
157            .metadata
158            .is_some_and(|metadata| metadata.force_disable_setup);
159        let perform_setup = !force_disable_setup && perform_setup_fn(version);
160
161        Ok(CheckStatus::Success(MuktiUpdateContext {
162            context: self,
163            version: version.clone(),
164            location: location.clone(),
165            bin_path_in_archive,
166            perform_setup,
167        }))
168    }
169
170    // ---
171    // Helper methods
172    // ---
173
174    fn resolve_version(
175        &self,
176        version_req: &UpdateVersionReq,
177    ) -> Result<(&Version, ReleaseVersionData), UpdateError> {
178        match version_req {
179            UpdateVersionReq::Latest => {
180                // Get the latest stable (non-prerelease) version.
181                let (version, release_data) = self
182                    .project
183                    .get_latest_matching(&VersionReq::STAR)
184                    .ok_or(UpdateError::NoStableVersion)?;
185                Ok((version, self.parse_release_data(release_data)))
186            }
187            UpdateVersionReq::Version(update_version) => self.get_version_data(update_version),
188            UpdateVersionReq::LatestPrerelease(kind) => self.get_latest_prerelease(*kind),
189        }
190    }
191
192    fn get_version_data(
193        &self,
194        version: &UpdateVersion,
195    ) -> Result<(&Version, ReleaseVersionData), UpdateError> {
196        let (version, release_data) = match version {
197            UpdateVersion::Exact(version) => {
198                self.project.get_version_data(version).ok_or_else(|| {
199                    let known = self
200                        .project
201                        .all_versions()
202                        .map(|(v, release_data)| (v.clone(), release_data.status))
203                        .collect();
204                    UpdateError::VersionNotFound {
205                        version: version.clone(),
206                        known,
207                    }
208                })?
209            }
210            UpdateVersion::Req(req) => self
211                .project
212                .get_latest_matching(req)
213                .ok_or_else(|| UpdateError::NoMatchForVersionReq { req: req.clone() })?,
214        };
215
216        Ok((version, self.parse_release_data(release_data)))
217    }
218
219    fn get_latest_prerelease(
220        &self,
221        kind: PrereleaseKind,
222    ) -> Result<(&Version, ReleaseVersionData), UpdateError> {
223        // all_versions() returns versions in descending order (most recent first).
224        for (version, release_data) in self.project.all_versions() {
225            if release_data.status == ReleaseStatus::Active && kind.matches(version) {
226                return Ok((version, self.parse_release_data(release_data)));
227            }
228        }
229
230        Err(UpdateError::NoVersionForPrereleaseKind { kind })
231    }
232
233    fn parse_release_data(
234        &self,
235        release_data: &mukti_metadata::ReleaseVersionData,
236    ) -> ReleaseVersionData {
237        // Parse the metadata into our custom format.
238        let metadata = if release_data.metadata.is_null() {
239            None
240        } else {
241            // Attempt to parse the metadata.
242            match serde_json::from_value::<NextestReleaseMetadata>(release_data.metadata.clone()) {
243                Ok(metadata) => Some(metadata),
244                Err(error) => {
245                    warn!(
246                        target: "nextest-runner::update",
247                        "failed to parse custom release metadata: {error}",
248                    );
249                    None
250                }
251            }
252        };
253
254        ReleaseVersionData {
255            release_url: release_data.release_url.clone(),
256            status: release_data.status,
257            locations: release_data.locations.clone(),
258            metadata,
259        }
260    }
261
262    fn target_triple(&self) -> String {
263        // In this case, use the build target, *not* `rustc -vV` output. This
264        // ensures that e.g. musl binary updates continue to use the musl
265        // target.
266        let current = Platform::build_target().expect("build target could not be detected");
267        let triple_str = current.triple_str();
268        if triple_str.ends_with("-apple-darwin") {
269            // Nextest builds a universal binary for Mac.
270            "universal-apple-darwin".to_owned()
271        } else {
272            triple_str.to_owned()
273        }
274    }
275}
276
277/// Like `mukti-metadata`'s `ReleaseVersionData`, except with parsed metadata.
278#[derive(Clone, Debug)]
279pub struct ReleaseVersionData {
280    /// Canonical URL for this release
281    pub release_url: String,
282
283    /// The status of a release
284    pub status: ReleaseStatus,
285
286    /// Release locations
287    pub locations: Vec<ReleaseLocation>,
288
289    /// Custom domain-specific information stored about this release.
290    pub metadata: Option<NextestReleaseMetadata>,
291}
292
293/// Nextest-specific release metadata.
294#[derive(Clone, Debug, Deserialize)]
295pub struct NextestReleaseMetadata {
296    /// Whether to force disable `cargo nextest self setup` for this version.
297    #[serde(default)]
298    pub force_disable_setup: bool,
299}
300
301/// The result of [`NextestReleases::check`].
302#[derive(Clone, Debug)]
303pub enum CheckStatus<'a> {
304    /// The current version is the same as the requested version.
305    AlreadyOnRequested(Version),
306
307    /// A downgrade was requested but wasn't allowed.
308    DowngradeNotAllowed {
309        /// The currently running version.
310        current_version: Version,
311
312        /// The requested version.
313        requested: Version,
314    },
315
316    /// All checks were performed successfully and we are ready to update.
317    Success(MuktiUpdateContext<'a>),
318}
319/// Context for an update.
320///
321/// Returned as part of the `Success` variant of [`CheckStatus`].
322#[derive(Clone, Debug)]
323#[non_exhaustive]
324pub struct MuktiUpdateContext<'a> {
325    /// The `MuktiReleases` context.
326    pub context: &'a NextestReleases,
327
328    /// The version being updated to.
329    pub version: Version,
330
331    /// The target-specific release location from which the package will be downloaded.
332    pub location: ReleaseLocation,
333
334    /// The path to the binary within the archive.
335    pub bin_path_in_archive: &'a Utf8Path,
336
337    /// Whether to run `cargo nextest self setup` as part of the update.
338    pub perform_setup: bool,
339}
340
341impl MuktiUpdateContext<'_> {
342    /// Performs the update.
343    pub fn do_update(&self) -> Result<(), UpdateError> {
344        // This method is adapted from self_update's update_extended.
345
346        let tmp_dir_parent = self.context.bin_install_path.parent().ok_or_else(|| {
347            UpdateError::CurrentExe(io::Error::new(
348                io::ErrorKind::InvalidData,
349                format!(
350                    "parent directory of current exe `{}` could not be determined",
351                    self.context.bin_install_path
352                ),
353            ))
354        })?;
355        let tmp_backup_dir_prefix = format!("__{}_backup", self.context.package_name);
356        let tmp_backup_filename = tmp_backup_dir_prefix.clone();
357
358        if cfg!(windows) {
359            // Windows executables can not be removed while they are running, which prevents clean up
360            // of the temporary directory by the `tempfile` crate after we move the running executable
361            // into it during an update. We clean up any previously created temporary directories here.
362            // Ignore errors during cleanup since this is not critical for completing the update.
363            let _ = cleanup_backup_temp_directories(
364                tmp_dir_parent,
365                &tmp_backup_dir_prefix,
366                &tmp_backup_filename,
367            );
368        }
369
370        let tmp_archive_dir_prefix = format!("{}_download", self.context.package_name);
371        let tmp_archive_dir = camino_tempfile::Builder::new()
372            .prefix(&tmp_archive_dir_prefix)
373            .tempdir_in(tmp_dir_parent)
374            .map_err(|error| UpdateError::TempDirCreate {
375                location: tmp_dir_parent.to_owned(),
376                error,
377            })?;
378        let tmp_dir_path: &Utf8Path = tmp_archive_dir.path();
379        let tmp_archive_path =
380            tmp_dir_path.join(format!("{}.{TAR_GZ_SUFFIX}", self.context.package_name));
381        let tmp_archive = fs::File::create(&tmp_archive_path).map_err(|error| {
382            UpdateError::TempArchiveCreate {
383                archive_path: tmp_archive_path.clone(),
384                error,
385            }
386        })?;
387        let mut tmp_archive_buf = BufWriter::new(tmp_archive);
388
389        let mut download = Download::from_url(&self.location.url);
390        let mut headers = http::header::HeaderMap::new();
391        headers.insert(
392            http::header::ACCEPT,
393            "application/octet-stream".parse().unwrap(),
394        );
395        download.set_headers(headers);
396        download.show_progress(true);
397        // TODO: set progress style
398
399        download
400            .download_to(&mut tmp_archive_buf)
401            .map_err(UpdateError::SelfUpdate)?;
402
403        debug!(target: "nextest-runner::update", "downloaded to {tmp_archive_path}");
404
405        let tmp_archive =
406            tmp_archive_buf
407                .into_inner()
408                .map_err(|error| UpdateError::TempArchiveWrite {
409                    archive_path: tmp_archive_path.clone(),
410                    error: error.into_error(),
411                })?;
412        tmp_archive
413            .sync_all()
414            .map_err(|error| UpdateError::TempArchiveWrite {
415                archive_path: tmp_archive_path.clone(),
416                error,
417            })?;
418        std::mem::drop(tmp_archive);
419
420        // Verify the checksum of the downloaded file if available.
421        let mut hasher = Sha256::default();
422        // Just read the file into memory for now -- it would be nice to have an
423        // incremental hasher that updates the hash as it's being downloaded,
424        // but it's not critical since our archives are quite small.
425        let mut tmp_archive =
426            fs::File::open(&tmp_archive_path).map_err(|error| UpdateError::TempArchiveRead {
427                archive_path: tmp_archive_path.clone(),
428                error,
429            })?;
430        io::copy(&mut tmp_archive, &mut hasher).map_err(|error| UpdateError::TempArchiveRead {
431            archive_path: tmp_archive_path.clone(),
432            error,
433        })?;
434        let hash = hasher.finalize();
435        let hash_str = hex::encode(hash);
436
437        match self.location.checksums.get(&DigestAlgorithm::SHA256) {
438            Some(checksum) => {
439                if checksum.0 != hash_str {
440                    return Err(UpdateError::ChecksumMismatch {
441                        expected: checksum.0.clone(),
442                        actual: hash_str,
443                    });
444                }
445                debug!(target: "nextest-runner::update", "SHA-256 checksum verified: {hash_str}");
446            }
447            None => {
448                warn!(target: "nextest-runner::update", "unable to verify SHA-256 checksum of downloaded archive ({hash_str})");
449            }
450        }
451
452        // Now extract data from this archive.
453        Extract::from_source(tmp_archive_path.as_std_path())
454            .archive(ArchiveKind::Tar(Some(Compression::Gz)))
455            .extract_file(
456                tmp_archive_dir.path().as_std_path(),
457                self.bin_path_in_archive,
458            )
459            .map_err(UpdateError::SelfUpdate)?;
460
461        // Since we're currently restricted to .tar.gz which carries metadata with it, there's no
462        // need to make this file executable.
463
464        let new_exe = tmp_dir_path.join(self.bin_path_in_archive);
465        debug!(target: "nextest-runner::update", "extracted to {new_exe}, replacing existing binary");
466
467        let tmp_backup_dir = camino_tempfile::Builder::new()
468            .prefix(&tmp_backup_dir_prefix)
469            .tempdir_in(tmp_dir_parent)
470            .map_err(|error| UpdateError::TempDirCreate {
471                location: tmp_dir_parent.to_owned(),
472                error,
473            })?;
474
475        let tmp_backup_dir_path: &Utf8Path = tmp_backup_dir.path();
476        let tmp_file_path = tmp_backup_dir_path.join(&tmp_backup_filename);
477
478        Move::from_source(&new_exe)
479            .replace_using_temp(&tmp_file_path)
480            .to_dest(&self.context.bin_install_path)?;
481
482        // Finally, run `cargo nextest self setup` if requested.
483        if self.perform_setup {
484            info!(target: "nextest-runner::update", "running `cargo nextest self setup`");
485            let mut cmd = std::process::Command::new(&self.context.bin_install_path);
486            cmd.args(["nextest", "self", "setup", "--source", "self-update"]);
487            let status = cmd.status().map_err(UpdateError::SelfSetup)?;
488            if !status.success() {
489                return Err(UpdateError::SelfSetup(io::Error::other(format!(
490                    "`cargo nextest self setup` failed with exit code {}",
491                    status
492                        .code()
493                        .map_or("(unknown)".to_owned(), |c| c.to_string())
494                ))));
495            }
496        }
497
498        Ok(())
499    }
500}
501
502/// Moves a file from the given path to the specified destination.
503///
504/// `source` and `dest` must be on the same filesystem.
505/// If `replace_using_temp` is specified, the destination file will be
506/// replaced using the given temporary path.
507/// If the existing `dest` file is a currently running long running program,
508/// `replace_using_temp` may run into errors cleaning up the temp dir.
509/// If that's the case for your use-case, consider not specifying a temp dir to use.
510///
511/// * Errors:
512///     * Io - copying / renaming
513#[derive(Debug)]
514struct Move<'a> {
515    source: &'a Utf8Path,
516    temp: Option<&'a Utf8Path>,
517}
518impl<'a> Move<'a> {
519    /// Specify source file
520    pub fn from_source(source: &'a Utf8Path) -> Move<'a> {
521        Self { source, temp: None }
522    }
523
524    /// If specified and the destination file already exists, the "destination"
525    /// file will be moved to the given temporary location before the "source"
526    /// file is moved to the "destination" file.
527    ///
528    /// In the event of an `io` error while renaming "source" to "destination",
529    /// the temporary file will be moved back to "destination".
530    ///
531    /// The `temp` dir must be explicitly provided since `rename` operations require
532    /// files to live on the same filesystem.
533    pub fn replace_using_temp(&mut self, temp: &'a Utf8Path) -> &mut Self {
534        self.temp = Some(temp);
535        self
536    }
537
538    /// Move source file to specified destination
539    pub fn to_dest(&self, dest: &Utf8Path) -> Result<(), UpdateError> {
540        match self.temp {
541            None => Self::fs_rename(self.source, dest),
542            Some(temp) => {
543                if dest.exists() {
544                    // Move the existing dest to a temp location so we can move it
545                    // back it there's an error. If the existing `dest` file is a
546                    // long running program, this may prevent the temp dir from
547                    // being cleaned up.
548                    Self::fs_rename(dest, temp)?;
549                    if let Err(e) = Self::fs_rename(self.source, dest) {
550                        Self::fs_rename(temp, dest)?;
551                        return Err(e);
552                    }
553                } else {
554                    Self::fs_rename(self.source, dest)?;
555                }
556                Ok(())
557            }
558        }
559    }
560
561    // ---
562    // Helper methods
563    // ---
564
565    fn fs_rename(source: &Utf8Path, dest: &Utf8Path) -> Result<(), UpdateError> {
566        fs::rename(source, dest).map_err(|error| UpdateError::FsRename {
567            source: source.to_owned(),
568            dest: dest.to_owned(),
569            error,
570        })
571    }
572}
573
574fn cleanup_backup_temp_directories(
575    tmp_dir_parent: &Utf8Path,
576    tmp_dir_prefix: &str,
577    expected_tmp_filename: &str,
578) -> io::Result<()> {
579    for entry in fs::read_dir(tmp_dir_parent)? {
580        let entry = entry?;
581        let tmp_dir_name = if let Ok(tmp_dir_name) = entry.file_name().into_string() {
582            tmp_dir_name
583        } else {
584            continue;
585        };
586
587        // For safety, check that the temporary directory contains only the expected backup
588        // binary file before removing. If subdirectories or other files exist then the user
589        // is using the temp directory for something else. This is unlikely, but we should
590        // be careful with `fs::remove_dir_all`.
591        let is_expected_tmp_file = |tmp_file_entry: std::io::Result<fs::DirEntry>| {
592            tmp_file_entry
593                .ok()
594                .filter(|e| e.file_name() == expected_tmp_filename)
595                .is_some()
596        };
597
598        if tmp_dir_name.starts_with(tmp_dir_prefix)
599            && fs::read_dir(entry.path())?.all(is_expected_tmp_file)
600        {
601            fs::remove_dir_all(entry.path())?;
602        }
603    }
604    Ok(())
605}
606
607const TAR_GZ_SUFFIX: &str = "tar.gz";
608
609/// Represents the user's requested version for an update.
610#[derive(Clone, Debug, Eq, PartialEq)]
611pub enum UpdateVersionReq {
612    /// Update to the latest stable (non-prerelease) version.
613    Latest,
614
615    /// Update to a specific version or version requirement.
616    Version(UpdateVersion),
617
618    /// Update to the latest prerelease of the given kind.
619    LatestPrerelease(PrereleaseKind),
620}
621
622/// The kind of prerelease to look for.
623#[derive(Clone, Copy, Debug, Eq, PartialEq)]
624pub enum PrereleaseKind {
625    /// Beta channel (includes beta `-b.N`, RC `-rc.N`, and stable versions).
626    Beta,
627
628    /// Release candidate channel (`-rc.N` and stable versions).
629    Rc,
630}
631
632impl PrereleaseKind {
633    /// Returns true if this version is acceptable for this prerelease kind.
634    ///
635    /// `Beta` accepts stable, RC, or beta versions.
636    ///
637    /// `Rc` accepts stable or RC versions (not beta).
638    pub fn matches(&self, version: &Version) -> bool {
639        // Stable versions are always acceptable.
640        if version.pre.is_empty() {
641            return true;
642        }
643        let pre_str = version.pre.as_str();
644        match self {
645            // Beta accepts everything: stable, -b.N, or -rc.N.
646            PrereleaseKind::Beta => pre_str.starts_with("b.") || pre_str.starts_with("rc."),
647            // RC accepts stable or -rc.N (not -b.N).
648            PrereleaseKind::Rc => pre_str.starts_with("rc."),
649        }
650    }
651
652    /// Returns a description of this prerelease kind for user-facing messages.
653    pub fn description(&self) -> &'static str {
654        match self {
655            PrereleaseKind::Beta => "beta or RC",
656            PrereleaseKind::Rc => "RC",
657        }
658    }
659}
660
661/// Represents the version this project is being updated to.
662#[derive(Clone, Debug, Eq, PartialEq)]
663pub enum UpdateVersion {
664    /// Update to this exact version.
665    Exact(Version),
666
667    /// Update to the latest non-pre-release, non-yanked version matching this [`VersionReq`].
668    Req(VersionReq),
669}
670
671/// Parses x.y.z as if it were =x.y.z, and provides error messages in the case of invalid
672/// values.
673impl FromStr for UpdateVersion {
674    type Err = UpdateVersionParseError;
675
676    fn from_str(input: &str) -> Result<Self, Self::Err> {
677        // Adapted from Cargo's source:
678        // https://github.com/rust-lang/cargo/blob/6b8e1922261bbed1894bf40069fb2d5dc8d62fb0/src/cargo/ops/cargo_install.rs#L760-L806
679
680        // If the version begins with character <, >, =, ^, ~ parse it as a
681        // version range, otherwise parse it as a specific version
682
683        if input == "latest" {
684            return Ok(UpdateVersion::Req(VersionReq::STAR));
685        }
686
687        let first = input
688            .chars()
689            .next()
690            .ok_or(UpdateVersionParseError::EmptyString)?;
691
692        let is_req = "<>=^~".contains(first) || input.contains('*');
693        if is_req {
694            match input.parse::<VersionReq>() {
695                Ok(v) => Ok(Self::Req(v)),
696                Err(error) => Err(UpdateVersionParseError::InvalidVersionReq {
697                    input: input.to_owned(),
698                    error,
699                }),
700            }
701        } else {
702            match input.parse::<Version>() {
703                Ok(v) => Ok(Self::Exact(v)),
704                Err(error) => Err(UpdateVersionParseError::InvalidVersion {
705                    input: input.to_owned(),
706                    error,
707                }),
708            }
709        }
710    }
711}