1use 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#[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(
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 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 let mut hasher = Sha256::default();
530 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 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 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 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#[derive(Debug)]
622struct Move<'a> {
623 source: &'a Utf8Path,
624 temp: Option<&'a Utf8Path>,
625}
626impl<'a> Move<'a> {
627 pub fn from_source(source: &'a Utf8Path) -> Move<'a> {
629 Self { source, temp: None }
630 }
631
632 pub fn replace_using_temp(&mut self, temp: &'a Utf8Path) -> &mut Self {
642 self.temp = Some(temp);
643 self
644 }
645
646 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 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 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 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#[cfg(not(any(target_arch = "riscv32", target_arch = "riscv64")))]
721fn ureq_agent() -> ureq::Agent {
722 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 .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#[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
774const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
777
778const TAR_GZ_SUFFIX: &str = "tar.gz";
779
780#[derive(Clone, Debug, Eq, PartialEq)]
782pub enum UpdateVersionReq {
783 Latest,
785
786 Version(UpdateVersion),
788
789 LatestPrerelease(PrereleaseKind),
791}
792
793#[derive(Clone, Copy, Debug, Eq, PartialEq)]
795pub enum PrereleaseKind {
796 Beta,
798
799 Rc,
801}
802
803impl PrereleaseKind {
804 pub fn matches(&self, version: &Version) -> bool {
810 if version.pre.is_empty() {
812 return true;
813 }
814 let pre_str = version.pre.as_str();
815 match self {
816 PrereleaseKind::Beta => pre_str.starts_with("b.") || pre_str.starts_with("rc."),
818 PrereleaseKind::Rc => pre_str.starts_with("rc."),
820 }
821 }
822
823 pub fn description(&self) -> &'static str {
825 match self {
826 PrereleaseKind::Beta => "beta or RC",
827 PrereleaseKind::Rc => "RC",
828 }
829 }
830}
831
832#[derive(Clone, Debug, Eq, PartialEq)]
834pub enum UpdateVersion {
835 Exact(Version),
837
838 Req(VersionReq),
840}
841
842impl FromStr for UpdateVersion {
845 type Err = UpdateVersionParseError;
846
847 fn from_str(input: &str) -> Result<Self, Self::Err> {
848 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}