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