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: &UpdateVersion,
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.get_version_data(version)?;
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 get_version_data(
175 &self,
176 version: &UpdateVersion,
177 ) -> Result<(&Version, ReleaseVersionData), UpdateError> {
178 let (version, release_data) = match version {
179 UpdateVersion::Exact(version) => {
180 self.project.get_version_data(version).ok_or_else(|| {
181 let known = self
182 .project
183 .all_versions()
184 .map(|(v, release_data)| (v.clone(), release_data.status))
185 .collect();
186 UpdateError::VersionNotFound {
187 version: version.clone(),
188 known,
189 }
190 })?
191 }
192 UpdateVersion::Req(req) => self
193 .project
194 .get_latest_matching(req)
195 .ok_or_else(|| UpdateError::NoMatchForVersionReq { req: req.clone() })?,
196 };
197
198 let metadata = if release_data.metadata.is_null() {
200 None
201 } else {
202 match serde_json::from_value::<NextestReleaseMetadata>(release_data.metadata.clone()) {
204 Ok(metadata) => Some(metadata),
205 Err(error) => {
206 warn!(
207 target: "nextest-runner::update",
208 "failed to parse custom release metadata: {error}",
209 );
210 None
211 }
212 }
213 };
214
215 let release_data = ReleaseVersionData {
216 release_url: release_data.release_url.clone(),
217 status: release_data.status,
218 locations: release_data.locations.clone(),
219 metadata,
220 };
221 Ok((version, release_data))
222 }
223
224 fn target_triple(&self) -> String {
225 let current = Platform::build_target().expect("build target could not be detected");
229 let triple_str = current.triple_str();
230 if triple_str.ends_with("-apple-darwin") {
231 "universal-apple-darwin".to_owned()
233 } else {
234 triple_str.to_owned()
235 }
236 }
237}
238
239#[derive(Clone, Debug)]
241pub struct ReleaseVersionData {
242 pub release_url: String,
244
245 pub status: ReleaseStatus,
247
248 pub locations: Vec<ReleaseLocation>,
250
251 pub metadata: Option<NextestReleaseMetadata>,
253}
254
255#[derive(Clone, Debug, Deserialize)]
257pub struct NextestReleaseMetadata {
258 #[serde(default)]
260 pub force_disable_setup: bool,
261}
262
263#[derive(Clone, Debug)]
265pub enum CheckStatus<'a> {
266 AlreadyOnRequested(Version),
268
269 DowngradeNotAllowed {
271 current_version: Version,
273
274 requested: Version,
276 },
277
278 Success(MuktiUpdateContext<'a>),
280}
281#[derive(Clone, Debug)]
285#[non_exhaustive]
286pub struct MuktiUpdateContext<'a> {
287 pub context: &'a NextestReleases,
289
290 pub version: Version,
292
293 pub location: ReleaseLocation,
295
296 pub bin_path_in_archive: &'a Utf8Path,
298
299 pub perform_setup: bool,
301}
302
303impl MuktiUpdateContext<'_> {
304 pub fn do_update(&self) -> Result<(), UpdateError> {
306 let tmp_dir_parent = self.context.bin_install_path.parent().ok_or_else(|| {
309 UpdateError::CurrentExe(io::Error::new(
310 io::ErrorKind::InvalidData,
311 format!(
312 "parent directory of current exe `{}` could not be determined",
313 self.context.bin_install_path
314 ),
315 ))
316 })?;
317 let tmp_backup_dir_prefix = format!("__{}_backup", self.context.package_name);
318 let tmp_backup_filename = tmp_backup_dir_prefix.clone();
319
320 if cfg!(windows) {
321 let _ = cleanup_backup_temp_directories(
326 tmp_dir_parent,
327 &tmp_backup_dir_prefix,
328 &tmp_backup_filename,
329 );
330 }
331
332 let tmp_archive_dir_prefix = format!("{}_download", self.context.package_name);
333 let tmp_archive_dir = camino_tempfile::Builder::new()
334 .prefix(&tmp_archive_dir_prefix)
335 .tempdir_in(tmp_dir_parent)
336 .map_err(|error| UpdateError::TempDirCreate {
337 location: tmp_dir_parent.to_owned(),
338 error,
339 })?;
340 let tmp_dir_path: &Utf8Path = tmp_archive_dir.path();
341 let tmp_archive_path =
342 tmp_dir_path.join(format!("{}.{TAR_GZ_SUFFIX}", self.context.package_name));
343 let tmp_archive = fs::File::create(&tmp_archive_path).map_err(|error| {
344 UpdateError::TempArchiveCreate {
345 archive_path: tmp_archive_path.clone(),
346 error,
347 }
348 })?;
349 let mut tmp_archive_buf = BufWriter::new(tmp_archive);
350
351 let mut download = Download::from_url(&self.location.url);
352 let mut headers = http::header::HeaderMap::new();
353 headers.insert(
354 http::header::ACCEPT,
355 "application/octet-stream".parse().unwrap(),
356 );
357 download.set_headers(headers);
358 download.show_progress(true);
359 download
362 .download_to(&mut tmp_archive_buf)
363 .map_err(UpdateError::SelfUpdate)?;
364
365 debug!(target: "nextest-runner::update", "downloaded to {tmp_archive_path}");
366
367 let tmp_archive =
368 tmp_archive_buf
369 .into_inner()
370 .map_err(|error| UpdateError::TempArchiveWrite {
371 archive_path: tmp_archive_path.clone(),
372 error: error.into_error(),
373 })?;
374 tmp_archive
375 .sync_all()
376 .map_err(|error| UpdateError::TempArchiveWrite {
377 archive_path: tmp_archive_path.clone(),
378 error,
379 })?;
380 std::mem::drop(tmp_archive);
381
382 let mut hasher = Sha256::default();
384 let mut tmp_archive =
388 fs::File::open(&tmp_archive_path).map_err(|error| UpdateError::TempArchiveRead {
389 archive_path: tmp_archive_path.clone(),
390 error,
391 })?;
392 io::copy(&mut tmp_archive, &mut hasher).map_err(|error| UpdateError::TempArchiveRead {
393 archive_path: tmp_archive_path.clone(),
394 error,
395 })?;
396 let hash = hasher.finalize();
397 let hash_str = hex::encode(hash);
398
399 match self.location.checksums.get(&DigestAlgorithm::SHA256) {
400 Some(checksum) => {
401 if checksum.0 != hash_str {
402 return Err(UpdateError::ChecksumMismatch {
403 expected: checksum.0.clone(),
404 actual: hash_str,
405 });
406 }
407 debug!(target: "nextest-runner::update", "SHA-256 checksum verified: {hash_str}");
408 }
409 None => {
410 warn!(target: "nextest-runner::update", "unable to verify SHA-256 checksum of downloaded archive ({hash_str})");
411 }
412 }
413
414 Extract::from_source(tmp_archive_path.as_std_path())
416 .archive(ArchiveKind::Tar(Some(Compression::Gz)))
417 .extract_file(
418 tmp_archive_dir.path().as_std_path(),
419 self.bin_path_in_archive,
420 )
421 .map_err(UpdateError::SelfUpdate)?;
422
423 let new_exe = tmp_dir_path.join(self.bin_path_in_archive);
427 debug!(target: "nextest-runner::update", "extracted to {new_exe}, replacing existing binary");
428
429 let tmp_backup_dir = camino_tempfile::Builder::new()
430 .prefix(&tmp_backup_dir_prefix)
431 .tempdir_in(tmp_dir_parent)
432 .map_err(|error| UpdateError::TempDirCreate {
433 location: tmp_dir_parent.to_owned(),
434 error,
435 })?;
436
437 let tmp_backup_dir_path: &Utf8Path = tmp_backup_dir.path();
438 let tmp_file_path = tmp_backup_dir_path.join(&tmp_backup_filename);
439
440 Move::from_source(&new_exe)
441 .replace_using_temp(&tmp_file_path)
442 .to_dest(&self.context.bin_install_path)?;
443
444 if self.perform_setup {
446 info!(target: "nextest-runner::update", "running `cargo nextest self setup`");
447 let mut cmd = std::process::Command::new(&self.context.bin_install_path);
448 cmd.args(["nextest", "self", "setup", "--source", "self-update"]);
449 let status = cmd.status().map_err(UpdateError::SelfSetup)?;
450 if !status.success() {
451 return Err(UpdateError::SelfSetup(io::Error::other(format!(
452 "`cargo nextest self setup` failed with exit code {}",
453 status
454 .code()
455 .map_or("(unknown)".to_owned(), |c| c.to_string())
456 ))));
457 }
458 }
459
460 Ok(())
461 }
462}
463
464#[derive(Debug)]
476struct Move<'a> {
477 source: &'a Utf8Path,
478 temp: Option<&'a Utf8Path>,
479}
480impl<'a> Move<'a> {
481 pub fn from_source(source: &'a Utf8Path) -> Move<'a> {
483 Self { source, temp: None }
484 }
485
486 pub fn replace_using_temp(&mut self, temp: &'a Utf8Path) -> &mut Self {
496 self.temp = Some(temp);
497 self
498 }
499
500 pub fn to_dest(&self, dest: &Utf8Path) -> Result<(), UpdateError> {
502 match self.temp {
503 None => Self::fs_rename(self.source, dest),
504 Some(temp) => {
505 if dest.exists() {
506 Self::fs_rename(dest, temp)?;
511 if let Err(e) = Self::fs_rename(self.source, dest) {
512 Self::fs_rename(temp, dest)?;
513 return Err(e);
514 }
515 } else {
516 Self::fs_rename(self.source, dest)?;
517 }
518 Ok(())
519 }
520 }
521 }
522
523 fn fs_rename(source: &Utf8Path, dest: &Utf8Path) -> Result<(), UpdateError> {
528 fs::rename(source, dest).map_err(|error| UpdateError::FsRename {
529 source: source.to_owned(),
530 dest: dest.to_owned(),
531 error,
532 })
533 }
534}
535
536fn cleanup_backup_temp_directories(
537 tmp_dir_parent: &Utf8Path,
538 tmp_dir_prefix: &str,
539 expected_tmp_filename: &str,
540) -> io::Result<()> {
541 for entry in fs::read_dir(tmp_dir_parent)? {
542 let entry = entry?;
543 let tmp_dir_name = if let Ok(tmp_dir_name) = entry.file_name().into_string() {
544 tmp_dir_name
545 } else {
546 continue;
547 };
548
549 let is_expected_tmp_file = |tmp_file_entry: std::io::Result<fs::DirEntry>| {
554 tmp_file_entry
555 .ok()
556 .filter(|e| e.file_name() == expected_tmp_filename)
557 .is_some()
558 };
559
560 if tmp_dir_name.starts_with(tmp_dir_prefix)
561 && fs::read_dir(entry.path())?.all(is_expected_tmp_file)
562 {
563 fs::remove_dir_all(entry.path())?;
564 }
565 }
566 Ok(())
567}
568
569const TAR_GZ_SUFFIX: &str = "tar.gz";
570
571#[derive(Clone, Debug, Eq, PartialEq)]
573pub enum UpdateVersion {
574 Exact(Version),
576
577 Req(VersionReq),
579}
580
581impl FromStr for UpdateVersion {
584 type Err = UpdateVersionParseError;
585
586 fn from_str(input: &str) -> Result<Self, Self::Err> {
587 if input == "latest" {
594 return Ok(UpdateVersion::Req(VersionReq::STAR));
595 }
596
597 let first = input
598 .chars()
599 .next()
600 .ok_or(UpdateVersionParseError::EmptyString)?;
601
602 let is_req = "<>=^~".contains(first) || input.contains('*');
603 if is_req {
604 match input.parse::<VersionReq>() {
605 Ok(v) => Ok(Self::Req(v)),
606 Err(error) => Err(UpdateVersionParseError::InvalidVersionReq {
607 input: input.to_owned(),
608 error,
609 }),
610 }
611 } else {
612 match input.parse::<Version>() {
613 Ok(v) => Ok(Self::Exact(v)),
614 Err(error) => Err(UpdateVersionParseError::InvalidVersion {
615 input: input.to_owned(),
616 error,
617 }),
618 }
619 }
620 }
621}