1use 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#[derive(Clone, Debug)]
33pub struct MuktiBackend {
34 pub url: String,
36
37 pub package_name: String,
39}
40
41impl MuktiBackend {
42 pub fn fetch_releases(&self, current_version: Version) -> Result<NextestReleases, UpdateError> {
44 info!(target: "nextest-runner::update", "checking for self-updates");
45 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 .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#[derive(Clone, Debug)]
89#[non_exhaustive]
90pub struct NextestReleases {
91 pub package_name: String,
93
94 pub project: MuktiProject,
96
97 pub current_version: Version,
99
100 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 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 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 fn resolve_version(
191 &self,
192 version_req: &UpdateVersionReq,
193 ) -> Result<(&Version, ReleaseVersionData), UpdateError> {
194 match version_req {
195 UpdateVersionReq::Latest => {
196 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 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 let metadata = if release_data.metadata.is_null() {
255 None
256 } else {
257 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 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 "universal-apple-darwin".to_owned()
287 } else {
288 triple_str.to_owned()
289 }
290 }
291}
292
293#[derive(Clone, Debug)]
295pub struct ReleaseVersionData {
296 pub release_url: String,
298
299 pub status: ReleaseStatus,
301
302 pub locations: Vec<ReleaseLocation>,
304
305 pub metadata: Option<NextestReleaseMetadata>,
307}
308
309#[derive(Clone, Debug, Deserialize)]
311pub struct NextestReleaseMetadata {
312 #[serde(default)]
314 pub force_disable_setup: bool,
315}
316
317#[derive(Clone, Debug)]
319pub enum CheckStatus<'a> {
320 AlreadyOnRequested(Version),
322
323 DowngradeNotAllowed {
325 current_version: Version,
327
328 requested: Version,
330 },
331
332 Success(MuktiUpdateContext<'a>),
334}
335
336#[derive(Clone, Debug, Default)]
338pub struct UpdateDisplayStyles {
339 pub progress_prefix: Style,
341
342 pub theme_characters: ThemeCharacters,
344}
345
346impl UpdateDisplayStyles {
347 pub fn colorize(&mut self) {
349 self.progress_prefix = Style::new().green().bold();
350 }
351}
352
353#[derive(Clone, Debug)]
357#[non_exhaustive]
358pub struct MuktiUpdateContext<'a> {
359 pub context: &'a NextestReleases,
361
362 pub version: Version,
364
365 pub location: ReleaseLocation,
367
368 pub bin_path_in_archive: &'a Utf8Path,
370
371 pub perform_setup: bool,
373}
374
375impl MuktiUpdateContext<'_> {
376 pub fn do_update(&self, styles: &UpdateDisplayStyles) -> Result<(), UpdateError> {
378 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 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 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 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 let mut hasher = Sha256::default();
525 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 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 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 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#[derive(Debug)]
617struct Move<'a> {
618 source: &'a Utf8Path,
619 temp: Option<&'a Utf8Path>,
620}
621impl<'a> Move<'a> {
622 pub fn from_source(source: &'a Utf8Path) -> Move<'a> {
624 Self { source, temp: None }
625 }
626
627 pub fn replace_using_temp(&mut self, temp: &'a Utf8Path) -> &mut Self {
637 self.temp = Some(temp);
638 self
639 }
640
641 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 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 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 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#[cfg(not(any(target_arch = "riscv32", target_arch = "riscv64")))]
716fn ureq_agent() -> ureq::Agent {
717 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 .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#[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
769const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
772
773const TAR_GZ_SUFFIX: &str = "tar.gz";
774
775#[derive(Clone, Debug, Eq, PartialEq)]
777pub enum UpdateVersionReq {
778 Latest,
780
781 Version(UpdateVersion),
783
784 LatestPrerelease(PrereleaseKind),
786}
787
788#[derive(Clone, Copy, Debug, Eq, PartialEq)]
790pub enum PrereleaseKind {
791 Beta,
793
794 Rc,
796}
797
798impl PrereleaseKind {
799 pub fn matches(&self, version: &Version) -> bool {
805 if version.pre.is_empty() {
807 return true;
808 }
809 let pre_str = version.pre.as_str();
810 match self {
811 PrereleaseKind::Beta => pre_str.starts_with("b.") || pre_str.starts_with("rc."),
813 PrereleaseKind::Rc => pre_str.starts_with("rc."),
815 }
816 }
817
818 pub fn description(&self) -> &'static str {
820 match self {
821 PrereleaseKind::Beta => "beta or RC",
822 PrereleaseKind::Rc => "RC",
823 }
824 }
825}
826
827#[derive(Clone, Debug, Eq, PartialEq)]
829pub enum UpdateVersion {
830 Exact(Version),
832
833 Req(VersionReq),
835}
836
837impl FromStr for UpdateVersion {
840 type Err = UpdateVersionParseError;
841
842 fn from_str(input: &str) -> Result<Self, Self::Err> {
843 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}