1use std::fs::{self, File, OpenOptions};
9use std::io::{Read, Write};
10use std::path::{Path, PathBuf};
11use std::time::Duration;
12
13use semver::Version;
14use serde::Deserialize;
15use sha2::{Digest, Sha256};
16
17#[cfg(unix)]
18use std::os::unix::fs::PermissionsExt;
19
20pub const REPO: &str = "Q1CHENL/mach";
22pub const GIT_URL: &str = "https://github.com/Q1CHENL/mach";
23const RELEASES_URL: &str = "https://api.github.com/repos/Q1CHENL/mach/releases?per_page=100";
24const RELEASE_DOWNLOAD_BASE: &str = "https://github.com/Q1CHENL/mach/releases/download";
25const CHECKSUMS_ASSET: &str = "SHA256SUMS";
26const USER_AGENT: &str = concat!("mach/", env!("CARGO_PKG_VERSION"));
27const TIMEOUT: Duration = Duration::from_secs(8);
28const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120);
29const MAX_TEXT_BYTES: u64 = 1024 * 1024;
30const MAX_BINARY_BYTES: u64 = 128 * 1024 * 1024;
31
32#[derive(Debug, Clone)]
33pub struct CheckResult {
34 pub current: String,
35 pub latest: String,
36 pub tag: String,
38 pub newer: bool,
39 pub prerelease: bool,
40 pub release_url: String,
41 pub asset_name: String,
43 pub asset_url: String,
44 pub checksums_url: String,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct InstallResult {
49 pub destination: PathBuf,
50 pub tag: String,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub(crate) struct DownloadProgress {
55 pub(crate) downloaded: u64,
56 pub(crate) total: Option<u64>,
57}
58
59impl CheckResult {
60 pub fn summary(&self) -> String {
62 if self.newer {
63 format!(
64 "Update available: v{} → v{} ({})",
65 self.current, self.latest, self.release_url
66 )
67 } else {
68 format!("Up to date (v{})", self.current)
69 }
70 }
71
72 pub fn install_hint(&self) -> String {
74 "mach update --install\n# or, for Cargo installs: cargo install --locked mach-tui".into()
75 }
76}
77
78pub fn current_version() -> &'static str {
80 crate::VERSION
81}
82
83pub fn check() -> Result<CheckResult, String> {
89 let current = current_version().to_string();
90 let body = http_get(RELEASES_URL)?;
91 let releases: Vec<GhRelease> = serde_json::from_str(&body)
92 .map_err(|e| format!("could not parse GitHub release JSON: {e}"))?;
93 let asset_name = current_asset_name()?;
94 let selected = select_release(&releases, &asset_name).ok_or_else(|| {
95 format!("no stable GitHub release ships both {asset_name} and {CHECKSUMS_ASSET}")
96 })?;
97 let latest = selected.version.to_string();
98 let newer = selected.version
99 > Version::parse(¤t)
100 .map_err(|e| format!("invalid current version {current:?}: {e}"))?;
101
102 Ok(CheckResult {
103 current,
104 latest,
105 tag: selected.tag,
106 newer,
107 prerelease: false,
108 release_url: selected.release_url,
109 asset_name,
110 asset_url: selected.asset_url,
111 checksums_url: selected.checksums_url,
112 })
113}
114
115#[derive(Debug)]
116struct SelectedRelease {
117 version: Version,
118 tag: String,
119 release_url: String,
120 asset_url: String,
121 checksums_url: String,
122}
123
124fn select_release(releases: &[GhRelease], asset_name: &str) -> Option<SelectedRelease> {
125 releases
126 .iter()
127 .filter(|release| !release.draft && !release.prerelease)
128 .filter_map(|release| {
129 let version = parse_stable_tag(&release.tag_name)?;
130 let asset_url = release.asset_url(asset_name)?;
131 let checksums_url = release.asset_url(CHECKSUMS_ASSET)?;
132 Some(SelectedRelease {
133 version,
134 tag: release.tag_name.clone(),
135 release_url: if release.html_url.is_empty() {
136 format!("{GIT_URL}/releases/tag/{}", release.tag_name)
137 } else {
138 release.html_url.clone()
139 },
140 asset_url: asset_url.to_string(),
141 checksums_url: checksums_url.to_string(),
142 })
143 })
144 .max_by(|a, b| a.version.cmp(&b.version))
145}
146
147fn current_asset_name() -> Result<String, String> {
148 let arch = match std::env::consts::ARCH {
149 "x86_64" => "x86_64",
150 "aarch64" => "aarch64",
151 other => return Err(format!("unsupported architecture {other:?}")),
152 };
153 let platform = match std::env::consts::OS {
154 "macos" => "apple-darwin",
155 "linux" if cfg!(target_env = "gnu") => "unknown-linux-gnu",
156 "linux" => return Err("this build does not target GNU libc".into()),
157 other => return Err(format!("unsupported operating system {other:?}")),
158 };
159 Ok(format!("mach-{arch}-{platform}"))
160}
161
162pub fn install(info: &CheckResult) -> Result<InstallResult, String> {
168 install_with_progress(info, |_| {})
169}
170
171pub(crate) fn install_with_progress(
172 info: &CheckResult,
173 progress: impl FnMut(DownloadProgress),
174) -> Result<InstallResult, String> {
175 validate_install_info(info)?;
176 let destination = install_destination()?;
177 let manifest = http_get_text(
178 &info.checksums_url,
179 DOWNLOAD_TIMEOUT,
180 "application/octet-stream",
181 map_download_err,
182 )
183 .map_err(|e| format!("could not download checksums for {}: {e}", info.tag))?;
184 let expected_sha = checksum_for_asset(&manifest, &info.asset_name)?;
185 download_verified_binary(&info.asset_url, &expected_sha, &destination, progress)?;
186 Ok(InstallResult {
187 destination,
188 tag: info.tag.clone(),
189 })
190}
191
192fn validate_install_info(info: &CheckResult) -> Result<(), String> {
193 if info.current != current_version() {
194 return Err(format!(
195 "release check was produced for v{}, but this binary is v{}",
196 info.current,
197 current_version()
198 ));
199 }
200 if !info.newer {
201 return Err("refusing to install a release that is not newer than this binary".into());
202 }
203 let expected_asset = current_asset_name()?;
204 if info.asset_name != expected_asset {
205 return Err(format!(
206 "refusing asset {} on this platform (expected {expected_asset})",
207 info.asset_name
208 ));
209 }
210 let selected_version = parse_stable_tag(&info.tag)
211 .ok_or_else(|| format!("invalid stable release tag {:?}", info.tag))?;
212 let latest = Version::parse(&info.latest)
213 .map_err(|e| format!("invalid selected release version {:?}: {e}", info.latest))?;
214 if selected_version != latest || !latest.pre.is_empty() || info.latest != latest.to_string() {
215 return Err("selected release tag/version is inconsistent or not stable".into());
216 }
217 let current = Version::parse(current_version())
218 .map_err(|e| format!("invalid built-in version {:?}: {e}", current_version()))?;
219 if latest <= current {
220 return Err(format!(
221 "refusing to install v{latest} over v{current}: updates must move forward"
222 ));
223 }
224 let expected_asset_url = release_asset_url(&info.tag, &info.asset_name);
225 if info.asset_url != expected_asset_url {
226 return Err(format!(
227 "selected binary URL is not bound to {} and {}",
228 info.tag, info.asset_name
229 ));
230 }
231 let expected_checksums_url = release_asset_url(&info.tag, CHECKSUMS_ASSET);
232 if info.checksums_url != expected_checksums_url {
233 return Err(format!(
234 "selected checksum URL is not bound to {}",
235 info.tag
236 ));
237 }
238 Ok(())
239}
240
241fn release_asset_url(tag: &str, asset: &str) -> String {
242 format!("{RELEASE_DOWNLOAD_BASE}/{tag}/{asset}")
243}
244
245fn install_destination() -> Result<PathBuf, String> {
246 let explicit_install_dir = std::env::var_os("MACH_INSTALL_DIR")
247 .filter(|value| !value.is_empty())
248 .map(PathBuf::from);
249 let home = dirs::home_dir();
250 let current_exe = std::env::current_exe().ok();
251 let cargo_home = std::env::var_os("CARGO_HOME")
252 .filter(|value| !value.is_empty())
253 .map(PathBuf::from);
254 resolve_install_destination(
255 explicit_install_dir.as_deref(),
256 home.as_deref(),
257 current_exe.as_deref(),
258 cargo_home.as_deref(),
259 )
260}
261
262fn resolve_install_destination(
263 explicit_install_dir: Option<&Path>,
264 home: Option<&Path>,
265 current_exe: Option<&Path>,
266 cargo_home: Option<&Path>,
267) -> Result<PathBuf, String> {
268 if let Some(install_dir) = explicit_install_dir {
269 return Ok(install_dir.join("mach"));
270 }
271
272 let home = home.ok_or_else(|| "could not determine the install directory".to_string())?;
273 let cargo_bin = cargo_home
274 .map(Path::to_path_buf)
275 .unwrap_or_else(|| home.join(".cargo"))
276 .join("bin");
277 if current_exe.and_then(Path::parent) == Some(cargo_bin.as_path()) {
278 return Err("this mach executable is managed by Cargo; update it with \
279 'cargo install --locked mach-tui', or set MACH_INSTALL_DIR to install a release \
280 binary elsewhere"
281 .into());
282 }
283 Ok(home.join(".local/bin/mach"))
284}
285
286fn checksum_for_asset(manifest: &str, asset_name: &str) -> Result<String, String> {
287 let mut found = None;
288 for line in manifest.lines() {
289 let mut fields = line.split_whitespace();
290 let Some(digest) = fields.next() else {
291 continue;
292 };
293 let Some(name) = fields.next() else {
294 continue;
295 };
296 if name.trim_start_matches('*') != asset_name {
297 continue;
298 }
299 if fields.next().is_some() {
300 return Err(format!(
301 "{CHECKSUMS_ASSET} contains a malformed entry for {asset_name}"
302 ));
303 }
304 if found.is_some() {
305 return Err(format!(
306 "{CHECKSUMS_ASSET} contains duplicate entries for {asset_name}"
307 ));
308 }
309 if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
310 return Err(format!(
311 "{CHECKSUMS_ASSET} contains an invalid digest for {asset_name}"
312 ));
313 }
314 found = Some(digest.to_ascii_lowercase());
315 }
316 found.ok_or_else(|| format!("{CHECKSUMS_ASSET} has no entry for {asset_name}"))
317}
318
319fn download_verified_binary(
320 url: &str,
321 expected_sha: &str,
322 destination: &Path,
323 progress: impl FnMut(DownloadProgress),
324) -> Result<(), String> {
325 let config = ureq::Agent::config_builder()
326 .timeout_global(Some(DOWNLOAD_TIMEOUT))
327 .build();
328 let agent: ureq::Agent = config.into();
329 let mut response = agent
330 .get(url)
331 .header("User-Agent", USER_AGENT)
332 .header("Accept", "application/octet-stream")
333 .call()
334 .map_err(map_download_err)?;
335 let total = response.body().content_length();
336 if total.is_some_and(|total| total > MAX_BINARY_BYTES) {
337 return Err(format!(
338 "release binary exceeds the {} MiB safety limit",
339 MAX_BINARY_BYTES / 1024 / 1024
340 ));
341 }
342 write_verified_binary(
343 response.body_mut().as_reader(),
344 expected_sha,
345 destination,
346 total,
347 progress,
348 )
349}
350
351fn write_verified_binary<R: Read>(
352 mut source: R,
353 expected_sha: &str,
354 destination: &Path,
355 expected_total: Option<u64>,
356 mut progress: impl FnMut(DownloadProgress),
357) -> Result<(), String> {
358 #[cfg(not(unix))]
359 return Err("self-update is supported only on Unix platforms".into());
360
361 #[cfg(unix)]
362 {
363 let parent = destination
364 .parent()
365 .filter(|path| !path.as_os_str().is_empty())
366 .ok_or_else(|| "install destination has no parent directory".to_string())?;
367 fs::create_dir_all(parent).map_err(|e| {
368 format!(
369 "could not create install directory {}: {e}",
370 parent.display()
371 )
372 })?;
373 let parent_dir = File::open(parent)
374 .map_err(|e| format!("could not open install directory {}: {e}", parent.display()))?;
375 let temp_path = parent.join(format!(".mach.{}.tmp", uuid::Uuid::new_v4()));
376 let mut temp_file = OpenOptions::new()
377 .write(true)
378 .create_new(true)
379 .open(&temp_path)
380 .map_err(|e| format!("could not create temporary binary: {e}"))?;
381
382 progress(DownloadProgress {
383 downloaded: 0,
384 total: expected_total,
385 });
386
387 let write_result = (|| -> Result<(), String> {
388 let mut hasher = Sha256::new();
389 let mut downloaded = 0_u64;
390 let mut buffer = [0_u8; 64 * 1024];
391 loop {
392 let read = source
393 .read(&mut buffer)
394 .map_err(|e| format!("could not read release binary: {e}"))?;
395 if read == 0 {
396 break;
397 }
398 downloaded = downloaded
399 .checked_add(read as u64)
400 .ok_or_else(|| "release binary is too large".to_string())?;
401 if downloaded > MAX_BINARY_BYTES {
402 return Err(format!(
403 "release binary exceeds the {} MiB safety limit",
404 MAX_BINARY_BYTES / 1024 / 1024
405 ));
406 }
407 hasher.update(&buffer[..read]);
408 temp_file
409 .write_all(&buffer[..read])
410 .map_err(|e| format!("could not write temporary binary: {e}"))?;
411 progress(DownloadProgress {
412 downloaded,
413 total: expected_total,
414 });
415 }
416
417 let actual_sha = format!("{:x}", hasher.finalize());
418 if actual_sha != expected_sha {
419 return Err(format!(
420 "SHA-256 verification failed (expected {expected_sha}, got {actual_sha})"
421 ));
422 }
423 temp_file
424 .set_permissions(fs::Permissions::from_mode(0o755))
425 .map_err(|e| format!("could not mark temporary binary executable: {e}"))?;
426 temp_file
427 .sync_all()
428 .map_err(|e| format!("could not sync temporary binary: {e}"))?;
429 Ok(())
430 })();
431 drop(temp_file);
432
433 if let Err(error) = write_result {
434 let _ = fs::remove_file(&temp_path);
435 return Err(error);
436 }
437 if let Err(error) = fs::rename(&temp_path, destination) {
438 let _ = fs::remove_file(&temp_path);
439 return Err(format!(
440 "could not replace {} atomically: {error}",
441 destination.display()
442 ));
443 }
444 parent_dir
445 .sync_all()
446 .map_err(|e| format!("could not sync install directory {}: {e}", parent.display()))?;
447 Ok(())
448 }
449}
450
451#[cfg(test)]
452fn sha256_hex(bytes: &[u8]) -> String {
453 format!("{:x}", Sha256::digest(bytes))
454}
455
456#[derive(Debug, Deserialize)]
457struct GhRelease {
458 tag_name: String,
459 #[serde(default)]
460 html_url: String,
461 #[serde(default)]
462 prerelease: bool,
463 #[serde(default)]
464 draft: bool,
465 #[serde(default)]
466 assets: Vec<GhAsset>,
467}
468
469impl GhRelease {
470 fn asset_url(&self, name: &str) -> Option<&str> {
471 self.assets
472 .iter()
473 .find(|asset| asset.name == name && !asset.browser_download_url.is_empty())
474 .map(|asset| asset.browser_download_url.as_str())
475 }
476}
477
478#[derive(Debug, Deserialize)]
479struct GhAsset {
480 name: String,
481 #[serde(default)]
482 browser_download_url: String,
483}
484
485fn http_get(url: &str) -> Result<String, String> {
486 http_get_text(url, TIMEOUT, "application/vnd.github+json", map_ureq_err)
487}
488
489fn http_get_text(
490 url: &str,
491 timeout: Duration,
492 accept: &str,
493 map_error: fn(ureq::Error) -> String,
494) -> Result<String, String> {
495 let config = ureq::Agent::config_builder()
496 .timeout_global(Some(timeout))
497 .build();
498 let agent: ureq::Agent = config.into();
499 let mut response = agent
500 .get(url)
501 .header("User-Agent", USER_AGENT)
502 .header("Accept", accept)
503 .call()
504 .map_err(map_error)?;
505 read_bounded_text(response.body_mut().as_reader(), MAX_TEXT_BYTES)
506}
507
508fn read_bounded_text<R: Read>(source: R, max_bytes: u64) -> Result<String, String> {
509 let mut bytes = Vec::new();
510 source
511 .take(max_bytes.saturating_add(1))
512 .read_to_end(&mut bytes)
513 .map_err(|e| format!("could not read response: {e}"))?;
514 if bytes.len() as u64 > max_bytes {
515 return Err(format!("response exceeds the {max_bytes}-byte limit"));
516 }
517 String::from_utf8(bytes).map_err(|e| format!("response is not valid UTF-8: {e}"))
518}
519
520fn map_download_err(error: ureq::Error) -> String {
521 match error {
522 ureq::Error::StatusCode(code) => format!("download HTTP {code}"),
523 other => format!("download failed: {other}"),
524 }
525}
526
527fn parse_stable_tag(tag: &str) -> Option<Version> {
528 if tag != tag.trim() {
529 return None;
530 }
531 let tag = tag.trim();
532 let normalized = tag.strip_prefix('v').unwrap_or(tag);
533 let version = Version::parse(normalized).ok()?;
534 if !version.pre.is_empty() || !version.build.is_empty() || normalized != version.to_string() {
535 return None;
536 }
537 Some(version)
538}
539
540fn map_ureq_err(e: ureq::Error) -> String {
541 match e {
542 ureq::Error::StatusCode(404) => {
543 "no GitHub releases yet — publish one, or install from git".into()
544 }
545 ureq::Error::StatusCode(code) => format!("GitHub API HTTP {code}"),
546 other => format!("network error: {other}"),
547 }
548}
549
550pub fn normalize_tag(tag: &str) -> String {
552 let tag = tag.trim();
553 tag.strip_prefix('v').unwrap_or(tag).to_string()
554}
555
556pub fn is_newer(latest: &str, current: &str) -> Option<bool> {
558 let a = Version::parse(&normalize_tag(latest)).ok()?;
559 let b = Version::parse(&normalize_tag(current)).ok()?;
560 Some(a > b)
561}
562
563#[cfg(test)]
564mod tests {
565 use super::*;
566
567 fn release(tag: &str, prerelease: bool, assets: &[(&str, &str)]) -> GhRelease {
568 GhRelease {
569 tag_name: tag.into(),
570 html_url: format!("https://github.test/releases/tag/{tag}"),
571 prerelease,
572 draft: false,
573 assets: assets
574 .iter()
575 .map(|(name, url)| GhAsset {
576 name: (*name).into(),
577 browser_download_url: (*url).into(),
578 })
579 .collect(),
580 }
581 }
582
583 fn valid_install_result() -> CheckResult {
584 let current = Version::parse(current_version()).unwrap();
585 let latest = Version::new(
586 current.major,
587 current.minor,
588 current.patch.checked_add(1).unwrap(),
589 );
590 let tag = format!("v{latest}");
591 let asset_name = current_asset_name().unwrap();
592 CheckResult {
593 current: current.to_string(),
594 latest: latest.to_string(),
595 tag: tag.clone(),
596 newer: true,
597 prerelease: false,
598 release_url: format!("https://github.test/releases/tag/{tag}"),
599 asset_url: release_asset_url(&tag, &asset_name),
600 checksums_url: release_asset_url(&tag, CHECKSUMS_ASSET),
601 asset_name,
602 }
603 }
604
605 #[test]
606 fn normalizes_v_prefix() {
607 assert_eq!(normalize_tag("v1.2.3"), "1.2.3");
608 assert_eq!(normalize_tag(" 1.0.0 "), "1.0.0");
609 }
610
611 #[test]
612 fn compares_semver() {
613 assert_eq!(is_newer("0.2.0", "0.1.0"), Some(true));
614 assert_eq!(is_newer("0.1.0", "0.1.0"), Some(false));
615 assert_eq!(is_newer("0.1.0", "0.2.0"), Some(false));
616 assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true));
617 assert_eq!(is_newer("0.1.1-rc.1", "0.1.0"), Some(true));
618 }
619
620 #[test]
621 fn stable_release_tags_must_be_canonical_and_not_prereleases() {
622 assert_eq!(parse_stable_tag("v1.2.3").unwrap().to_string(), "1.2.3");
623 assert!(parse_stable_tag("1.2.3+build.4").is_none());
624 assert!(parse_stable_tag("v01.2.3").is_none());
625 assert!(parse_stable_tag("v1.2.3-rc.1").is_none());
626 assert!(parse_stable_tag(" v1.2.3").is_none());
627 }
628
629 #[test]
630 fn install_hint_prefers_verified_self_update() {
631 let r = CheckResult {
632 current: "0.1.0".into(),
633 latest: "0.1.0".into(),
634 tag: "v0.1.0".into(),
635 newer: false,
636 prerelease: false,
637 release_url: String::new(),
638 asset_name: "mach-aarch64-apple-darwin".into(),
639 asset_url: "https://example.test/mach".into(),
640 checksums_url: "https://example.test/SHA256SUMS".into(),
641 };
642 let h = r.install_hint();
643 assert!(h.contains("mach update --install"));
644 assert!(h.contains("cargo install --locked mach-tui"));
645 assert!(!h.contains("curl"));
646 }
647
648 #[test]
649 fn cargo_managed_binary_requires_cargo_or_an_explicit_release_destination() {
650 let home = Path::new("/home/alice");
651 let cargo_home = home.join(".cargo");
652 let current_exe = cargo_home.join("bin/mach");
653
654 let error = resolve_install_destination(None, Some(home), Some(¤t_exe), None)
655 .expect_err("a Cargo-managed executable must not create a shadow release install");
656 assert!(error.contains("Cargo"));
657 assert!(error.contains("cargo install --locked mach-tui"));
658
659 assert_eq!(
660 resolve_install_destination(
661 Some(Path::new("/opt/mach/bin")),
662 Some(home),
663 Some(¤t_exe),
664 None,
665 )
666 .unwrap(),
667 PathBuf::from("/opt/mach/bin/mach"),
668 "an explicit destination is an intentional ownership change"
669 );
670
671 let custom_cargo_home = Path::new("/srv/cargo");
672 let custom_exe = custom_cargo_home.join("bin/mach");
673 assert!(
674 resolve_install_destination(
675 None,
676 Some(home),
677 Some(&custom_exe),
678 Some(custom_cargo_home),
679 )
680 .is_err(),
681 "CARGO_HOME must participate in ownership detection"
682 );
683 }
684
685 #[test]
686 fn selector_ignores_legacy_prereleases_and_binds_required_assets() {
687 let releases = vec![
688 release("v1.21.9", false, &[]),
689 release(
690 "v2.0.0-rc.1",
691 false,
692 &[
693 ("mach-x86_64-unknown-linux-gnu", "https://bad/tagged-rc"),
694 (CHECKSUMS_ASSET, "https://bad/tagged-rc-sums"),
695 ],
696 ),
697 release(
698 "v0.2.0-rc.1",
699 true,
700 &[
701 ("mach-x86_64-unknown-linux-gnu", "https://bad/rc"),
702 (CHECKSUMS_ASSET, "https://bad/rc-sums"),
703 ],
704 ),
705 release(
706 "v0.1.2",
707 false,
708 &[
709 ("mach-x86_64-unknown-linux-gnu", "https://good/mach"),
710 (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
711 ],
712 ),
713 ];
714
715 let selected = select_release(&releases, "mach-x86_64-unknown-linux-gnu")
716 .expect("stable release with both assets");
717
718 assert_eq!(selected.version.to_string(), "0.1.2");
719 assert_eq!(selected.tag, "v0.1.2");
720 assert_eq!(selected.asset_url, "https://good/mach");
721 assert_eq!(selected.checksums_url, "https://good/SHA256SUMS");
722 }
723
724 #[test]
725 fn selector_allows_a_legitimate_major_upgrade() {
726 let releases = vec![release(
727 "v1.0.0",
728 false,
729 &[
730 ("mach-aarch64-apple-darwin", "https://good/mach"),
731 (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
732 ],
733 )];
734
735 let selected =
736 select_release(&releases, "mach-aarch64-apple-darwin").expect("major upgrade");
737 assert_eq!(selected.version.to_string(), "1.0.0");
738 }
739
740 #[test]
741 fn selector_still_returns_the_latest_release_when_this_build_is_ahead() {
742 let releases = vec![release(
743 "v0.9.0",
744 false,
745 &[
746 ("mach-aarch64-apple-darwin", "https://good/mach"),
747 (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
748 ],
749 )];
750
751 let selected = select_release(&releases, "mach-aarch64-apple-darwin")
752 .expect("an older eligible release is still the latest published release");
753 assert_eq!(selected.version.to_string(), "0.9.0");
754 assert_eq!(
755 is_newer(&selected.version.to_string(), "1.0.0"),
756 Some(false)
757 );
758 }
759
760 #[test]
761 fn selector_rejects_releases_missing_the_binary_or_checksum_manifest() {
762 let releases = vec![
763 release(
764 "v0.3.0",
765 false,
766 &[(CHECKSUMS_ASSET, "https://bad/only-sums")],
767 ),
768 release(
769 "v0.2.0",
770 false,
771 &[("mach-x86_64-unknown-linux-gnu", "https://bad/only-bin")],
772 ),
773 ];
774
775 assert!(select_release(&releases, "mach-x86_64-unknown-linux-gnu").is_none());
776 }
777
778 #[test]
779 fn checksum_parser_requires_one_exact_valid_asset_entry() {
780 let digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
781 assert_eq!(
782 checksum_for_asset(
783 &format!("{digest} mach-aarch64-apple-darwin\n"),
784 "mach-aarch64-apple-darwin",
785 )
786 .unwrap(),
787 digest,
788 );
789 assert!(checksum_for_asset(&format!("{digest} mach-other\n"), "mach").is_err());
790 assert!(checksum_for_asset(&format!("{digest} mach\n{digest} mach\n"), "mach",).is_err());
791 assert!(checksum_for_asset(&format!("{digest} mach extra\n"), "mach").is_err());
792 }
793
794 #[test]
795 fn installer_rejects_urls_not_bound_to_the_selected_tag_and_asset() {
796 let mut result = valid_install_result();
797 validate_install_info(&result).unwrap();
798
799 result.asset_url.push_str("?wrong-release");
800 assert!(validate_install_info(&result).is_err());
801 }
802
803 #[test]
804 fn installer_rejects_stale_or_non_update_check_results() {
805 let mut stale = valid_install_result();
806 stale.current = "0.0.0".into();
807 assert!(
808 validate_install_info(&stale)
809 .unwrap_err()
810 .contains("produced for")
811 );
812
813 let mut not_newer = valid_install_result();
814 not_newer.newer = false;
815 assert!(
816 validate_install_info(¬_newer)
817 .unwrap_err()
818 .contains("not newer")
819 );
820 }
821
822 #[test]
823 fn installer_rejects_reinstalls_and_downgrades() {
824 let mut reinstall = valid_install_result();
825 reinstall.latest = current_version().into();
826 reinstall.tag = format!("v{}", current_version());
827 reinstall.asset_url = release_asset_url(&reinstall.tag, &reinstall.asset_name);
828 reinstall.checksums_url = release_asset_url(&reinstall.tag, CHECKSUMS_ASSET);
829 assert!(
830 validate_install_info(&reinstall)
831 .unwrap_err()
832 .contains("must move forward")
833 );
834
835 let current = Version::parse(current_version()).unwrap();
836 let lower = Version::new(0, 0, 0);
837 assert!(lower < current, "test package version must be above 0.0.0");
838 let mut downgrade = valid_install_result();
839 downgrade.latest = lower.to_string();
840 downgrade.tag = format!("v{lower}");
841 downgrade.asset_url = release_asset_url(&downgrade.tag, &downgrade.asset_name);
842 downgrade.checksums_url = release_asset_url(&downgrade.tag, CHECKSUMS_ASSET);
843 assert!(
844 validate_install_info(&downgrade)
845 .unwrap_err()
846 .contains("must move forward")
847 );
848 }
849
850 #[test]
851 fn text_responses_are_bounded() {
852 assert_eq!(
853 read_bounded_text(std::io::Cursor::new(b"four"), 4).unwrap(),
854 "four"
855 );
856 assert!(
857 read_bounded_text(std::io::Cursor::new(b"oversized"), 4)
858 .unwrap_err()
859 .contains("4-byte limit")
860 );
861 }
862
863 #[test]
864 fn verified_replace_preserves_the_existing_binary_on_hash_failure() {
865 let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
866 fs::create_dir(&dir).unwrap();
867 let destination = dir.join("mach");
868 fs::write(&destination, b"old binary").unwrap();
869
870 let error = write_verified_binary(
871 std::io::Cursor::new(b"corrupt download"),
872 &"0".repeat(64),
873 &destination,
874 None,
875 |_| {},
876 )
877 .unwrap_err();
878
879 assert!(error.contains("SHA-256"));
880 assert_eq!(fs::read(&destination).unwrap(), b"old binary");
881 fs::remove_dir_all(dir).unwrap();
882 }
883
884 #[test]
885 fn verified_replace_installs_an_executable_binary() {
886 let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
887 fs::create_dir(&dir).unwrap();
888 let destination = dir.join("mach");
889 let binary = b"verified binary";
890 let digest = sha256_hex(binary);
891
892 write_verified_binary(
893 std::io::Cursor::new(binary),
894 &digest,
895 &destination,
896 None,
897 |_| {},
898 )
899 .unwrap();
900
901 assert_eq!(fs::read(&destination).unwrap(), binary);
902 #[cfg(unix)]
903 {
904 use std::os::unix::fs::PermissionsExt;
905 assert_eq!(
906 fs::metadata(&destination).unwrap().permissions().mode() & 0o777,
907 0o755
908 );
909 }
910 fs::remove_dir_all(dir).unwrap();
911 }
912
913 #[test]
914 fn verified_replace_reports_monotonic_download_progress() {
915 let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
916 fs::create_dir(&dir).unwrap();
917 let destination = dir.join("mach");
918 let binary = vec![b'x'; 150_000];
919 let digest = sha256_hex(&binary);
920 let mut progress = Vec::new();
921
922 write_verified_binary(
923 std::io::Cursor::new(&binary),
924 &digest,
925 &destination,
926 Some(binary.len() as u64),
927 |event| progress.push(event),
928 )
929 .unwrap();
930
931 assert_eq!(
932 progress.first(),
933 Some(&DownloadProgress {
934 downloaded: 0,
935 total: Some(binary.len() as u64),
936 })
937 );
938 assert_eq!(
939 progress.last(),
940 Some(&DownloadProgress {
941 downloaded: binary.len() as u64,
942 total: Some(binary.len() as u64),
943 })
944 );
945 assert!(
946 progress
947 .windows(2)
948 .all(|pair| pair[0].downloaded <= pair[1].downloaded)
949 );
950 fs::remove_dir_all(dir).unwrap();
951 }
952}