1use 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#[derive(Clone, Debug)]
25pub struct MuktiBackend {
26 pub url: String,
28
29 pub package_name: String,
31}
32
33impl MuktiBackend {
34 pub fn fetch_releases(&self, current_version: Version) -> Result<NextestReleases, UpdateError> {
36 info!(target: "nextest-runner::update", "checking for self-updates");
37 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#[derive(Clone, Debug)]
73#[non_exhaustive]
74pub struct NextestReleases {
75 pub package_name: String,
77
78 pub project: MuktiProject,
80
81 pub current_version: Version,
83
84 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 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 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 fn resolve_version(
175 &self,
176 version_req: &UpdateVersionReq,
177 ) -> Result<(&Version, ReleaseVersionData), UpdateError> {
178 match version_req {
179 UpdateVersionReq::Latest => {
180 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 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 let metadata = if release_data.metadata.is_null() {
239 None
240 } else {
241 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 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 "universal-apple-darwin".to_owned()
271 } else {
272 triple_str.to_owned()
273 }
274 }
275}
276
277#[derive(Clone, Debug)]
279pub struct ReleaseVersionData {
280 pub release_url: String,
282
283 pub status: ReleaseStatus,
285
286 pub locations: Vec<ReleaseLocation>,
288
289 pub metadata: Option<NextestReleaseMetadata>,
291}
292
293#[derive(Clone, Debug, Deserialize)]
295pub struct NextestReleaseMetadata {
296 #[serde(default)]
298 pub force_disable_setup: bool,
299}
300
301#[derive(Clone, Debug)]
303pub enum CheckStatus<'a> {
304 AlreadyOnRequested(Version),
306
307 DowngradeNotAllowed {
309 current_version: Version,
311
312 requested: Version,
314 },
315
316 Success(MuktiUpdateContext<'a>),
318}
319#[derive(Clone, Debug)]
323#[non_exhaustive]
324pub struct MuktiUpdateContext<'a> {
325 pub context: &'a NextestReleases,
327
328 pub version: Version,
330
331 pub location: ReleaseLocation,
333
334 pub bin_path_in_archive: &'a Utf8Path,
336
337 pub perform_setup: bool,
339}
340
341impl MuktiUpdateContext<'_> {
342 pub fn do_update(&self) -> Result<(), UpdateError> {
344 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 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 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 let mut hasher = Sha256::default();
422 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 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 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 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#[derive(Debug)]
514struct Move<'a> {
515 source: &'a Utf8Path,
516 temp: Option<&'a Utf8Path>,
517}
518impl<'a> Move<'a> {
519 pub fn from_source(source: &'a Utf8Path) -> Move<'a> {
521 Self { source, temp: None }
522 }
523
524 pub fn replace_using_temp(&mut self, temp: &'a Utf8Path) -> &mut Self {
534 self.temp = Some(temp);
535 self
536 }
537
538 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 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 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 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#[derive(Clone, Debug, Eq, PartialEq)]
611pub enum UpdateVersionReq {
612 Latest,
614
615 Version(UpdateVersion),
617
618 LatestPrerelease(PrereleaseKind),
620}
621
622#[derive(Clone, Copy, Debug, Eq, PartialEq)]
624pub enum PrereleaseKind {
625 Beta,
627
628 Rc,
630}
631
632impl PrereleaseKind {
633 pub fn matches(&self, version: &Version) -> bool {
639 if version.pre.is_empty() {
641 return true;
642 }
643 let pre_str = version.pre.as_str();
644 match self {
645 PrereleaseKind::Beta => pre_str.starts_with("b.") || pre_str.starts_with("rc."),
647 PrereleaseKind::Rc => pre_str.starts_with("rc."),
649 }
650 }
651
652 pub fn description(&self) -> &'static str {
654 match self {
655 PrereleaseKind::Beta => "beta or RC",
656 PrereleaseKind::Rc => "RC",
657 }
658 }
659}
660
661#[derive(Clone, Debug, Eq, PartialEq)]
663pub enum UpdateVersion {
664 Exact(Version),
666
667 Req(VersionReq),
669}
670
671impl FromStr for UpdateVersion {
674 type Err = UpdateVersionParseError;
675
676 fn from_str(input: &str) -> Result<Self, Self::Err> {
677 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}