1use std::fs::{self, File, OpenOptions};
10use std::io::{ErrorKind, Read, Write};
11use std::path::{Path, PathBuf};
12use std::thread;
13use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
14
15use chrono::Utc;
16use semver::Version;
17use serde::Deserialize;
18use sha2::{Digest, Sha256};
19
20#[cfg(unix)]
21use std::os::unix::fs::PermissionsExt;
22
23pub const REPO: &str = "Q1CHENL/mach";
25pub const GIT_URL: &str = "https://github.com/Q1CHENL/mach";
26const RELEASES_URL: &str = "https://api.github.com/repos/Q1CHENL/mach/releases?per_page=100";
27const RELEASE_DOWNLOAD_BASE: &str = "https://github.com/Q1CHENL/mach/releases/download";
28const CHECKSUMS_ASSET: &str = "SHA256SUMS";
29const USER_AGENT: &str = concat!("mach/", env!("CARGO_PKG_VERSION"));
30const TIMEOUT: Duration = Duration::from_secs(8);
31const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120);
32const MAX_TEXT_BYTES: u64 = 1024 * 1024;
33const MAX_BINARY_BYTES: u64 = 128 * 1024 * 1024;
34const RELEASE_RECEIPT_DIR: &str = ".mach-release-install";
35const INSTALL_LOCK_DIR: &str = ".mach-install.lock";
36const INSTALL_LOCK_OWNER: &str = "owner";
37const INSTALL_LOCK_WAIT: Duration = Duration::from_secs(30);
38const INSTALL_LOCK_POLL: Duration = Duration::from_millis(100);
39
40#[derive(Debug, Clone)]
41pub struct CheckResult {
42 pub current: String,
43 pub latest: String,
44 pub tag: String,
46 pub newer: bool,
47 pub prerelease: bool,
48 pub release_url: String,
49 pub asset_name: String,
51 pub asset_url: String,
52 pub checksums_url: String,
53}
54
55#[derive(Debug)]
56pub(crate) enum Conditional<T> {
57 Modified { value: T, etag: Option<String> },
58 NotModified,
59}
60
61pub(crate) type CheckResponse = Conditional<CheckResult>;
62
63#[derive(Debug)]
64pub(crate) struct CheckFailure {
65 pub(crate) message: String,
66 pub(crate) retry_at: Option<i64>,
67}
68
69impl CheckFailure {
70 fn new(message: impl Into<String>) -> Self {
71 Self {
72 message: message.into(),
73 retry_at: None,
74 }
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct InstallResult {
80 pub destination: PathBuf,
81 pub tag: String,
82 pub disposition: InstallDisposition,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum InstallDisposition {
87 Installed,
88 AlreadyCurrent,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub(crate) struct DownloadProgress {
93 pub(crate) downloaded: u64,
94 pub(crate) total: Option<u64>,
95}
96
97impl CheckResult {
98 pub fn summary(&self) -> String {
100 if self.newer {
101 format!(
102 "Update available: v{} → v{} ({})",
103 self.current, self.latest, self.release_url
104 )
105 } else {
106 format!("Up to date (v{})", self.current)
107 }
108 }
109
110 pub fn install_hint(&self) -> String {
112 "mach update --install\n# or, for Cargo installs: cargo install --locked mach-tui".into()
113 }
114}
115
116pub fn current_version() -> &'static str {
118 crate::VERSION
119}
120
121pub fn check() -> Result<CheckResult, String> {
127 match check_with_etag(None).map_err(|error| error.message)? {
128 CheckResponse::Modified { value: info, .. } => Ok(info),
129 CheckResponse::NotModified => {
130 Err("GitHub returned 304 without a conditional request".into())
131 }
132 }
133}
134
135pub(crate) fn check_with_etag(etag: Option<&str>) -> Result<CheckResponse, CheckFailure> {
137 let current = current_version().to_string();
138 let ReleaseDocument::Modified { value: body, etag } = fetch_releases(RELEASES_URL, etag)?
139 else {
140 return Ok(CheckResponse::NotModified);
141 };
142 let releases: Vec<GhRelease> = serde_json::from_str(&body)
143 .map_err(|e| CheckFailure::new(format!("could not parse GitHub release JSON: {e}")))?;
144 let asset_name = current_asset_name().map_err(CheckFailure::new)?;
145 let selected = select_release(&releases, &asset_name).ok_or_else(|| {
146 CheckFailure::new(format!(
147 "no stable GitHub release ships both {asset_name} and {CHECKSUMS_ASSET}"
148 ))
149 })?;
150 let latest = selected.version.to_string();
151 let newer = selected.version
152 > Version::parse(¤t)
153 .map_err(|e| CheckFailure::new(format!("invalid current version {current:?}: {e}")))?;
154
155 Ok(CheckResponse::Modified {
156 value: CheckResult {
157 current,
158 latest,
159 tag: selected.tag,
160 newer,
161 prerelease: false,
162 release_url: selected.release_url,
163 asset_name,
164 asset_url: selected.asset_url,
165 checksums_url: selected.checksums_url,
166 },
167 etag,
168 })
169}
170
171#[derive(Debug)]
172struct SelectedRelease {
173 version: Version,
174 tag: String,
175 release_url: String,
176 asset_url: String,
177 checksums_url: String,
178}
179
180fn select_release(releases: &[GhRelease], asset_name: &str) -> Option<SelectedRelease> {
181 releases
182 .iter()
183 .filter(|release| !release.draft && !release.prerelease)
184 .filter_map(|release| {
185 let version = parse_stable_tag(&release.tag_name)?;
186 let asset_url = release.asset_url(asset_name)?;
187 let checksums_url = release.asset_url(CHECKSUMS_ASSET)?;
188 Some(SelectedRelease {
189 version,
190 tag: release.tag_name.clone(),
191 release_url: if release.html_url.is_empty() {
192 format!("{GIT_URL}/releases/tag/{}", release.tag_name)
193 } else {
194 release.html_url.clone()
195 },
196 asset_url: asset_url.to_string(),
197 checksums_url: checksums_url.to_string(),
198 })
199 })
200 .max_by(|a, b| a.version.cmp(&b.version))
201}
202
203fn current_asset_name() -> Result<String, String> {
204 let arch = match std::env::consts::ARCH {
205 "x86_64" => "x86_64",
206 "aarch64" => "aarch64",
207 other => return Err(format!("unsupported architecture {other:?}")),
208 };
209 let platform = match std::env::consts::OS {
210 "macos" => "apple-darwin",
211 "linux" if cfg!(target_env = "gnu") => "unknown-linux-gnu",
212 "linux" => return Err("this build does not target GNU libc".into()),
213 other => return Err(format!("unsupported operating system {other:?}")),
214 };
215 Ok(format!("mach-{arch}-{platform}"))
216}
217
218pub fn install(info: &CheckResult) -> Result<InstallResult, String> {
225 install_with_progress(info, |_| {})
226}
227
228pub(crate) fn install_with_progress(
229 info: &CheckResult,
230 progress: impl FnMut(DownloadProgress),
231) -> Result<InstallResult, String> {
232 let target_version = validate_install_info(info)?;
233 let destination = install_destination()?;
234 let manifest = download_checksum_manifest(&info.checksums_url)
235 .map_err(|e| format!("could not download checksums for {}: {e}", info.tag))?;
236 let expected_sha = checksum_for_asset(&manifest, &info.asset_name)?;
237 let (installed_version, disposition) = download_verified_binary(
238 &info.asset_url,
239 &expected_sha,
240 &destination,
241 &target_version,
242 progress,
243 )?;
244 Ok(InstallResult {
245 destination,
246 tag: format!("v{installed_version}"),
247 disposition,
248 })
249}
250
251fn validate_install_info(info: &CheckResult) -> Result<Version, String> {
252 if info.current != current_version() {
253 return Err(format!(
254 "release check was produced for v{}, but this binary is v{}",
255 info.current,
256 current_version()
257 ));
258 }
259 if !info.newer {
260 return Err("refusing to install a release that is not newer than this binary".into());
261 }
262 let expected_asset = current_asset_name()?;
263 if info.asset_name != expected_asset {
264 return Err(format!(
265 "refusing asset {} on this platform (expected {expected_asset})",
266 info.asset_name
267 ));
268 }
269 let selected_version = parse_stable_tag(&info.tag)
270 .ok_or_else(|| format!("invalid stable release tag {:?}", info.tag))?;
271 let latest = Version::parse(&info.latest)
272 .map_err(|e| format!("invalid selected release version {:?}: {e}", info.latest))?;
273 if selected_version != latest || !is_canonical_stable_version(&info.latest, &latest) {
274 return Err("selected release tag/version is inconsistent or not stable".into());
275 }
276 let current = Version::parse(current_version())
277 .map_err(|e| format!("invalid built-in version {:?}: {e}", current_version()))?;
278 if latest <= current {
279 return Err(format!(
280 "refusing to install v{latest} over v{current}: updates must move forward"
281 ));
282 }
283 let expected_asset_url = release_asset_url(&info.tag, &info.asset_name);
284 if info.asset_url != expected_asset_url {
285 return Err(format!(
286 "selected binary URL is not bound to {} and {}",
287 info.tag, info.asset_name
288 ));
289 }
290 let expected_checksums_url = release_asset_url(&info.tag, CHECKSUMS_ASSET);
291 if info.checksums_url != expected_checksums_url {
292 return Err(format!(
293 "selected checksum URL is not bound to {}",
294 info.tag
295 ));
296 }
297 Ok(latest)
298}
299
300fn release_asset_url(tag: &str, asset: &str) -> String {
301 format!("{RELEASE_DOWNLOAD_BASE}/{tag}/{asset}")
302}
303
304fn install_destination() -> Result<PathBuf, String> {
305 let explicit_install_dir = std::env::var_os("MACH_INSTALL_DIR")
306 .filter(|value| !value.is_empty())
307 .map(PathBuf::from);
308 let home = dirs::home_dir();
309 let current_exe = std::env::current_exe().ok();
310 let cargo_home = std::env::var_os("CARGO_HOME")
311 .filter(|value| !value.is_empty())
312 .map(PathBuf::from);
313 resolve_install_destination(
314 explicit_install_dir.as_deref(),
315 home.as_deref(),
316 current_exe.as_deref(),
317 cargo_home.as_deref(),
318 )
319}
320
321fn resolve_install_destination(
322 explicit_install_dir: Option<&Path>,
323 home: Option<&Path>,
324 current_exe: Option<&Path>,
325 cargo_home: Option<&Path>,
326) -> Result<PathBuf, String> {
327 if let Some(install_dir) = explicit_install_dir {
328 return Ok(install_dir.join("mach"));
329 }
330
331 let home = home.ok_or_else(|| "could not determine the install directory".to_string())?;
332 let current_exe =
333 current_exe.ok_or_else(|| "could not determine the current mach executable".to_string())?;
334 let default_destination = home.join(".local/bin/mach");
335 if receipted_release_version(current_exe)?.is_some() {
336 return Ok(current_exe.to_path_buf());
337 }
338
339 let cargo_bin = cargo_home
340 .map(Path::to_path_buf)
341 .unwrap_or_else(|| home.join(".cargo"))
342 .join("bin");
343 if is_cargo_managed(current_exe, &cargo_bin) {
344 return Err("Installation managed by Cargo: \
345 cargo install --locked mach-tui"
346 .into());
347 }
348 if install_paths_match(current_exe, &default_destination) {
349 return Ok(default_destination);
350 }
351
352 let current_parent = current_exe
353 .parent()
354 .filter(|path| !path.as_os_str().is_empty())
355 .map(Path::to_path_buf)
356 .unwrap_or_else(|| PathBuf::from("/path/to/mach"));
357 Err(format!(
358 "this mach executable at {} is managed by a package manager or another installer; update \
359 it there, or set MACH_INSTALL_DIR={} to replace it with a checksum-verified release binary",
360 current_exe.display(),
361 current_parent.display()
362 ))
363}
364
365fn install_paths_match(left: &Path, right: &Path) -> bool {
366 fn normalize_parent(path: &Path) -> PathBuf {
367 let Some(parent) = path.parent() else {
368 return path.to_path_buf();
369 };
370 let parent = fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
371 path.file_name()
372 .map_or(parent.clone(), |name| parent.join(name))
373 }
374
375 normalize_parent(left) == normalize_parent(right)
376}
377
378fn is_cargo_managed(current_exe: &Path, cargo_bin: &Path) -> bool {
379 let Some(parent) = current_exe.parent() else {
380 return false;
381 };
382 if install_paths_match(parent, cargo_bin) {
383 return true;
384 }
385 if parent.file_name().and_then(|name| name.to_str()) != Some("bin") {
386 return false;
387 }
388 let Some(root) = parent.parent() else {
389 return false;
390 };
391 root.join(".crates2.json").is_file() || root.join(".crates.toml").is_file()
392}
393
394fn checksum_for_asset(manifest: &str, asset_name: &str) -> Result<String, String> {
395 let mut found = None;
396 for line in manifest.lines() {
397 let mut fields = line.split_whitespace();
398 let Some(digest) = fields.next() else {
399 continue;
400 };
401 let Some(name) = fields.next() else {
402 continue;
403 };
404 if name.trim_start_matches('*') != asset_name {
405 continue;
406 }
407 if fields.next().is_some() {
408 return Err(format!(
409 "{CHECKSUMS_ASSET} contains a malformed entry for {asset_name}"
410 ));
411 }
412 if found.is_some() {
413 return Err(format!(
414 "{CHECKSUMS_ASSET} contains duplicate entries for {asset_name}"
415 ));
416 }
417 if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
418 return Err(format!(
419 "{CHECKSUMS_ASSET} contains an invalid digest for {asset_name}"
420 ));
421 }
422 found = Some(digest.to_ascii_lowercase());
423 }
424 found.ok_or_else(|| format!("{CHECKSUMS_ASSET} has no entry for {asset_name}"))
425}
426
427fn download_verified_binary(
428 url: &str,
429 expected_sha: &str,
430 destination: &Path,
431 target_version: &Version,
432 progress: impl FnMut(DownloadProgress),
433) -> Result<(Version, InstallDisposition), String> {
434 let config = ureq::Agent::config_builder()
435 .timeout_global(Some(DOWNLOAD_TIMEOUT))
436 .build();
437 let agent: ureq::Agent = config.into();
438 let mut response = agent
439 .get(url)
440 .header("User-Agent", USER_AGENT)
441 .header("Accept", "application/octet-stream")
442 .call()
443 .map_err(map_download_err)?;
444 let total = response.body().content_length();
445 if total.is_some_and(|total| total > MAX_BINARY_BYTES) {
446 return Err(format!(
447 "release binary exceeds the {} MiB safety limit",
448 MAX_BINARY_BYTES / 1024 / 1024
449 ));
450 }
451 write_verified_binary(
452 response.body_mut().as_reader(),
453 expected_sha,
454 destination,
455 target_version,
456 total,
457 progress,
458 )
459}
460
461fn write_verified_binary<R: Read>(
462 mut source: R,
463 expected_sha: &str,
464 destination: &Path,
465 target_version: &Version,
466 expected_total: Option<u64>,
467 mut progress: impl FnMut(DownloadProgress),
468) -> Result<(Version, InstallDisposition), String> {
469 #[cfg(not(unix))]
470 return Err("self-update is supported only on Unix platforms".into());
471
472 #[cfg(unix)]
473 {
474 if expected_sha.len() != 64 || !expected_sha.bytes().all(|byte| byte.is_ascii_hexdigit()) {
475 return Err("expected release digest is not a SHA-256 digest".into());
476 }
477 let expected_sha = expected_sha.to_ascii_lowercase();
478 let parent = destination
479 .parent()
480 .filter(|path| !path.as_os_str().is_empty())
481 .ok_or_else(|| "install destination has no parent directory".to_string())?;
482 fs::create_dir_all(parent).map_err(|e| {
483 format!(
484 "could not create install directory {}: {e}",
485 parent.display()
486 )
487 })?;
488 let parent_dir = File::open(parent)
489 .map_err(|e| format!("could not open install directory {}: {e}", parent.display()))?;
490 let temp_path = parent.join(format!(".mach.{}.tmp", uuid::Uuid::new_v4()));
491 let mut temp_file = OpenOptions::new()
492 .write(true)
493 .create_new(true)
494 .open(&temp_path)
495 .map_err(|e| format!("could not create temporary binary: {e}"))?;
496
497 progress(DownloadProgress {
498 downloaded: 0,
499 total: expected_total,
500 });
501
502 let write_result = (|| -> Result<String, String> {
503 let mut hasher = Sha256::new();
504 let mut downloaded = 0_u64;
505 let mut buffer = [0_u8; 64 * 1024];
506 loop {
507 let read = source
508 .read(&mut buffer)
509 .map_err(|e| format!("could not read release binary: {e}"))?;
510 if read == 0 {
511 break;
512 }
513 downloaded = downloaded
514 .checked_add(read as u64)
515 .ok_or_else(|| "release binary is too large".to_string())?;
516 if downloaded > MAX_BINARY_BYTES {
517 return Err(format!(
518 "release binary exceeds the {} MiB safety limit",
519 MAX_BINARY_BYTES / 1024 / 1024
520 ));
521 }
522 hasher.update(&buffer[..read]);
523 temp_file
524 .write_all(&buffer[..read])
525 .map_err(|e| format!("could not write temporary binary: {e}"))?;
526 progress(DownloadProgress {
527 downloaded,
528 total: expected_total,
529 });
530 }
531
532 let actual_sha = format!("{:x}", hasher.finalize());
533 if actual_sha != expected_sha {
534 return Err(format!(
535 "SHA-256 verification failed (expected {expected_sha}, got {actual_sha})"
536 ));
537 }
538 temp_file
539 .set_permissions(fs::Permissions::from_mode(0o755))
540 .map_err(|e| format!("could not mark temporary binary executable: {e}"))?;
541 temp_file
542 .sync_all()
543 .map_err(|e| format!("could not sync temporary binary: {e}"))?;
544 Ok(actual_sha)
545 })();
546 drop(temp_file);
547
548 let actual_sha = match write_result {
549 Ok(actual_sha) => actual_sha,
550 Err(error) => {
551 let _ = fs::remove_file(&temp_path);
552 return Err(error);
553 }
554 };
555
556 let install_result = (|| -> Result<(Version, InstallDisposition), String> {
557 let _lock = InstallLock::acquire(parent)?;
558 if let Some(installed_version) =
559 receipted_release_version(destination)?.filter(|version| version >= target_version)
560 {
561 return Ok((installed_version, InstallDisposition::AlreadyCurrent));
562 }
563
564 let (installed_version, receipt_update) =
565 record_release_version(parent, &actual_sha, target_version)?;
566 if let Err(error) = fs::rename(&temp_path, destination) {
567 let rollback_error = receipt_update.rollback().err();
568 let mut message = format!(
569 "could not replace {} atomically: {error}",
570 destination.display()
571 );
572 if let Some(rollback_error) = rollback_error {
573 message.push_str(&format!(
574 "; could not roll back release receipt: {rollback_error}"
575 ));
576 }
577 return Err(message);
578 }
579 parent_dir.sync_all().map_err(|e| {
580 format!("could not sync install directory {}: {e}", parent.display())
581 })?;
582 Ok((installed_version, InstallDisposition::Installed))
583 })();
584
585 if temp_path.exists() {
586 let _ = fs::remove_file(&temp_path);
587 }
588 install_result
589 }
590}
591
592#[cfg(unix)]
593fn receipted_release_version(destination: &Path) -> Result<Option<Version>, String> {
594 let Some(parent) = destination.parent() else {
595 return Ok(None);
596 };
597 let receipt_dir = parent.join(RELEASE_RECEIPT_DIR);
598 if !receipt_dir.is_dir() || !destination.is_file() {
599 return Ok(None);
600 }
601 let digest = sha256_file(destination)?;
602 let receipt = receipt_dir.join(digest);
603 if !receipt.is_file() {
604 return Ok(None);
605 }
606 read_receipt_version(&receipt).map(Some)
607}
608
609#[cfg(not(unix))]
610fn receipted_release_version(_destination: &Path) -> Result<Option<Version>, String> {
611 Ok(None)
612}
613
614#[cfg(unix)]
615fn sha256_file(path: &Path) -> Result<String, String> {
616 let metadata = fs::metadata(path)
617 .map_err(|e| format!("could not inspect installed binary {}: {e}", path.display()))?;
618 if metadata.len() > MAX_BINARY_BYTES {
619 return Err(format!(
620 "installed binary {} exceeds the {} MiB safety limit",
621 path.display(),
622 MAX_BINARY_BYTES / 1024 / 1024
623 ));
624 }
625 let mut file = File::open(path)
626 .map_err(|e| format!("could not open installed binary {}: {e}", path.display()))?;
627 let mut hasher = Sha256::new();
628 let mut buffer = [0_u8; 64 * 1024];
629 loop {
630 let read = file
631 .read(&mut buffer)
632 .map_err(|e| format!("could not read installed binary {}: {e}", path.display()))?;
633 if read == 0 {
634 break;
635 }
636 hasher.update(&buffer[..read]);
637 }
638 Ok(format!("{:x}", hasher.finalize()))
639}
640
641#[cfg(unix)]
642fn read_receipt_version(path: &Path) -> Result<Version, String> {
643 let file = File::open(path)
644 .map_err(|e| format!("could not open release receipt {}: {e}", path.display()))?;
645 let text = read_bounded_text(file, 128)
646 .map_err(|e| format!("invalid release receipt {}: {e}", path.display()))?;
647 let value = text
648 .strip_suffix('\n')
649 .filter(|value| !value.is_empty() && !value.contains(['\r', '\n']))
650 .ok_or_else(|| format!("invalid release receipt {}", path.display()))?;
651 let version = Version::parse(value)
652 .map_err(|e| format!("invalid release receipt {}: {e}", path.display()))?;
653 if !is_canonical_stable_version(value, &version) {
654 return Err(format!("invalid release receipt {}", path.display()));
655 }
656 Ok(version)
657}
658
659#[cfg(unix)]
660fn record_release_version(
661 parent: &Path,
662 digest: &str,
663 target_version: &Version,
664) -> Result<(Version, ReceiptUpdate), String> {
665 let receipt_dir = parent.join(RELEASE_RECEIPT_DIR);
666 fs::create_dir_all(&receipt_dir).map_err(|e| {
667 format!(
668 "could not create release receipt directory {}: {e}",
669 receipt_dir.display()
670 )
671 })?;
672 let receipt = receipt_dir.join(digest);
673 let previous_version = if receipt.is_file() {
674 let recorded = read_receipt_version(&receipt)?;
675 if recorded >= *target_version {
676 return Ok((recorded, ReceiptUpdate::Unchanged));
677 }
678 Some(recorded)
679 } else {
680 None
681 };
682
683 write_release_receipt(parent, &receipt, target_version)?;
684 let update = match previous_version {
685 Some(previous) => ReceiptUpdate::Replaced { receipt, previous },
686 None => ReceiptUpdate::Created(receipt),
687 };
688 Ok((target_version.clone(), update))
689}
690
691#[cfg(unix)]
692fn write_release_receipt(parent: &Path, receipt: &Path, version: &Version) -> Result<(), String> {
693 let receipt_dir = receipt
694 .parent()
695 .ok_or_else(|| "release receipt has no parent directory".to_string())?;
696 let temp_path = receipt_dir.join(format!(".receipt.{}.tmp", uuid::Uuid::new_v4()));
697 let write_result = (|| -> Result<(), String> {
698 let mut file = OpenOptions::new()
699 .write(true)
700 .create_new(true)
701 .open(&temp_path)
702 .map_err(|e| format!("could not create release receipt: {e}"))?;
703 writeln!(file, "{version}").map_err(|e| format!("could not write release receipt: {e}"))?;
704 file.sync_all()
705 .map_err(|e| format!("could not sync release receipt: {e}"))?;
706 fs::rename(&temp_path, receipt)
707 .map_err(|e| format!("could not publish release receipt: {e}"))?;
708 File::open(receipt_dir)
709 .and_then(|directory| directory.sync_all())
710 .map_err(|e| format!("could not sync release receipt directory: {e}"))?;
711 File::open(parent)
712 .and_then(|directory| directory.sync_all())
713 .map_err(|e| format!("could not sync install directory {}: {e}", parent.display()))?;
714 Ok(())
715 })();
716 if write_result.is_err() && temp_path.exists() {
717 let _ = fs::remove_file(&temp_path);
718 }
719 write_result
720}
721
722#[cfg(unix)]
723enum ReceiptUpdate {
724 Unchanged,
725 Created(PathBuf),
726 Replaced { receipt: PathBuf, previous: Version },
727}
728
729#[cfg(unix)]
730impl ReceiptUpdate {
731 fn rollback(self) -> Result<(), String> {
732 let receipt = match self {
733 Self::Unchanged => return Ok(()),
734 Self::Replaced { receipt, previous } => {
735 let parent = receipt
736 .parent()
737 .and_then(Path::parent)
738 .ok_or_else(|| "release receipt directory has no parent".to_string())?;
739 return write_release_receipt(parent, &receipt, &previous);
740 }
741 Self::Created(receipt) => receipt,
742 };
743 let receipt_dir = receipt
744 .parent()
745 .ok_or_else(|| "release receipt has no parent directory".to_string())?;
746 let parent = receipt_dir
747 .parent()
748 .ok_or_else(|| "release receipt directory has no parent".to_string())?;
749 fs::remove_file(&receipt)
750 .map_err(|e| format!("could not remove {}: {e}", receipt.display()))?;
751 File::open(receipt_dir)
752 .and_then(|directory| directory.sync_all())
753 .map_err(|e| format!("could not sync release receipt directory: {e}"))?;
754 File::open(parent)
755 .and_then(|directory| directory.sync_all())
756 .map_err(|e| format!("could not sync install directory {}: {e}", parent.display()))
757 }
758}
759
760#[cfg(unix)]
761struct InstallLock {
762 path: PathBuf,
763 owner_record: String,
764}
765
766#[cfg(unix)]
767impl InstallLock {
768 fn acquire(parent: &Path) -> Result<Self, String> {
769 let path = parent.join(INSTALL_LOCK_DIR);
770 let started = Instant::now();
771 loop {
772 match fs::create_dir(&path) {
773 Ok(()) => {
774 let timestamp = SystemTime::now()
775 .duration_since(UNIX_EPOCH)
776 .unwrap_or_default()
777 .as_secs();
778 let owner_record = format!("{timestamp} {}\n", uuid::Uuid::new_v4());
779 let owner_path = path.join(INSTALL_LOCK_OWNER);
780 let initialize = (|| -> Result<(), String> {
781 let mut owner = OpenOptions::new()
782 .write(true)
783 .create_new(true)
784 .open(&owner_path)
785 .map_err(|e| format!("could not create install lock owner: {e}"))?;
786 owner
787 .write_all(owner_record.as_bytes())
788 .map_err(|e| format!("could not write install lock owner: {e}"))?;
789 owner
790 .sync_all()
791 .map_err(|e| format!("could not sync install lock owner: {e}"))?;
792 File::open(&path)
793 .and_then(|directory| directory.sync_all())
794 .map_err(|e| format!("could not sync install lock: {e}"))?;
795 Ok(())
796 })();
797 if let Err(error) = initialize {
798 let _ = fs::remove_file(owner_path);
799 let _ = fs::remove_dir(&path);
800 return Err(error);
801 }
802 return Ok(Self { path, owner_record });
803 }
804 Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
805 Err(error) => {
806 return Err(format!(
807 "could not acquire install lock {}: {error}",
808 path.display()
809 ));
810 }
811 }
812 if started.elapsed() >= INSTALL_LOCK_WAIT {
813 return Err(format!(
814 "timed out waiting for another installer holding {}; if no installer is \
815 running, remove this stale lock directory",
816 path.display()
817 ));
818 }
819 thread::sleep(INSTALL_LOCK_POLL);
820 }
821 }
822}
823
824#[cfg(unix)]
825impl Drop for InstallLock {
826 fn drop(&mut self) {
827 let owner = self.path.join(INSTALL_LOCK_OWNER);
828 if fs::read_to_string(&owner).ok().as_deref() == Some(self.owner_record.as_str()) {
829 let _ = fs::remove_file(owner);
830 let _ = fs::remove_dir(&self.path);
831 }
832 }
833}
834
835#[cfg(test)]
836fn sha256_hex(bytes: &[u8]) -> String {
837 format!("{:x}", Sha256::digest(bytes))
838}
839
840#[derive(Debug, Deserialize)]
841struct GhRelease {
842 tag_name: String,
843 #[serde(default)]
844 html_url: String,
845 #[serde(default)]
846 prerelease: bool,
847 #[serde(default)]
848 draft: bool,
849 #[serde(default)]
850 assets: Vec<GhAsset>,
851}
852
853impl GhRelease {
854 fn asset_url(&self, name: &str) -> Option<&str> {
855 self.assets
856 .iter()
857 .find(|asset| asset.name == name && !asset.browser_download_url.is_empty())
858 .map(|asset| asset.browser_download_url.as_str())
859 }
860}
861
862#[derive(Debug, Deserialize)]
863struct GhAsset {
864 name: String,
865 #[serde(default)]
866 browser_download_url: String,
867}
868
869type ReleaseDocument = Conditional<String>;
870
871fn fetch_releases(url: &str, etag: Option<&str>) -> Result<ReleaseDocument, CheckFailure> {
872 let config = ureq::Agent::config_builder()
873 .timeout_global(Some(TIMEOUT))
874 .http_status_as_error(false)
875 .build();
876 let agent: ureq::Agent = config.into();
877 let mut request = agent
878 .get(url)
879 .header("User-Agent", USER_AGENT)
880 .header("Accept", "application/vnd.github+json");
881 if let Some(etag) = etag {
882 request = request.header("If-None-Match", etag);
883 }
884 let mut response = request
885 .call()
886 .map_err(|error| CheckFailure::new(map_ureq_err(error)))?;
887 let status = response.status().as_u16();
888 if status == 304 {
889 return Ok(ReleaseDocument::NotModified);
890 }
891 if status != 200 {
892 let now = Utc::now().timestamp();
893 let retry_at = response
894 .headers()
895 .get("Retry-After")
896 .and_then(|value| value.to_str().ok())
897 .and_then(|value| parse_retry_after(value, now))
898 .or_else(|| {
899 let remaining = response
900 .headers()
901 .get("X-RateLimit-Remaining")
902 .and_then(|value| value.to_str().ok());
903 (remaining == Some("0"))
904 .then(|| {
905 response
906 .headers()
907 .get("X-RateLimit-Reset")
908 .and_then(|value| value.to_str().ok())
909 .and_then(parse_nonnegative_decimal)
910 })
911 .flatten()
912 });
913 let message = if status == 404 {
914 "no GitHub releases yet — publish one, or install from git".into()
915 } else {
916 format!("GitHub API HTTP {status}")
917 };
918 return Err(CheckFailure { message, retry_at });
919 }
920 let response_etag = response
921 .headers()
922 .get("ETag")
923 .and_then(|value| value.to_str().ok())
924 .map(str::to_owned);
925 let body = read_bounded_text(response.body_mut().as_reader(), MAX_TEXT_BYTES)
926 .map_err(CheckFailure::new)?;
927 Ok(ReleaseDocument::Modified {
928 value: body,
929 etag: response_etag,
930 })
931}
932
933fn parse_retry_after(value: &str, now: i64) -> Option<i64> {
934 let value = value.trim();
935 if let Some(seconds) = parse_nonnegative_decimal(value) {
936 return Some(now.saturating_add(seconds));
937 }
938 let timestamp = httpdate::parse_http_date(value).ok()?;
939 let seconds = timestamp.duration_since(UNIX_EPOCH).ok()?.as_secs();
940 Some(i64::try_from(seconds).unwrap_or(i64::MAX))
941}
942
943fn parse_nonnegative_decimal(value: &str) -> Option<i64> {
944 let value = value.trim();
945 if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
946 return None;
947 }
948 Some(value.bytes().fold(0_i64, |number, byte| {
949 number
950 .saturating_mul(10)
951 .saturating_add(i64::from(byte - b'0'))
952 }))
953}
954
955fn download_checksum_manifest(url: &str) -> Result<String, String> {
956 let config = ureq::Agent::config_builder()
957 .timeout_global(Some(DOWNLOAD_TIMEOUT))
958 .build();
959 let agent: ureq::Agent = config.into();
960 let mut response = agent
961 .get(url)
962 .header("User-Agent", USER_AGENT)
963 .header("Accept", "application/octet-stream")
964 .call()
965 .map_err(map_download_err)?;
966 read_bounded_text(response.body_mut().as_reader(), MAX_TEXT_BYTES)
967}
968
969fn read_bounded_text<R: Read>(source: R, max_bytes: u64) -> Result<String, String> {
970 let mut bytes = Vec::new();
971 source
972 .take(max_bytes.saturating_add(1))
973 .read_to_end(&mut bytes)
974 .map_err(|e| format!("could not read response: {e}"))?;
975 if bytes.len() as u64 > max_bytes {
976 return Err(format!("response exceeds the {max_bytes}-byte limit"));
977 }
978 String::from_utf8(bytes).map_err(|e| format!("response is not valid UTF-8: {e}"))
979}
980
981fn map_download_err(error: ureq::Error) -> String {
982 match error {
983 ureq::Error::StatusCode(code) => format!("download HTTP {code}"),
984 other => format!("download failed: {other}"),
985 }
986}
987
988fn parse_stable_tag(tag: &str) -> Option<Version> {
989 if tag != tag.trim() {
990 return None;
991 }
992 let tag = tag.trim();
993 let normalized = tag.strip_prefix('v').unwrap_or(tag);
994 parse_stable_version(normalized)
995}
996
997pub(crate) fn parse_stable_version(value: &str) -> Option<Version> {
998 let version = Version::parse(value).ok()?;
999 is_canonical_stable_version(value, &version).then_some(version)
1000}
1001
1002fn is_canonical_stable_version(value: &str, version: &Version) -> bool {
1003 version.pre.is_empty() && version.build.is_empty() && value == version.to_string()
1004}
1005
1006fn map_ureq_err(e: ureq::Error) -> String {
1007 match e {
1008 ureq::Error::StatusCode(404) => {
1009 "no GitHub releases yet — publish one, or install from git".into()
1010 }
1011 ureq::Error::StatusCode(code) => format!("GitHub API HTTP {code}"),
1012 other => format!("network error: {other}"),
1013 }
1014}
1015
1016pub fn normalize_tag(tag: &str) -> String {
1018 let tag = tag.trim();
1019 tag.strip_prefix('v').unwrap_or(tag).to_string()
1020}
1021
1022pub fn is_newer(latest: &str, current: &str) -> Option<bool> {
1024 let a = Version::parse(&normalize_tag(latest)).ok()?;
1025 let b = Version::parse(&normalize_tag(current)).ok()?;
1026 Some(a > b)
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031 use super::*;
1032 use std::net::TcpListener;
1033 use std::sync::mpsc;
1034
1035 fn serve_once(response: impl Into<String>) -> (String, mpsc::Receiver<String>) {
1036 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1037 let address = listener.local_addr().unwrap();
1038 let (request_tx, request_rx) = mpsc::channel();
1039 let response = response.into();
1040 std::thread::spawn(move || {
1041 let (mut stream, _) = listener.accept().unwrap();
1042 let mut request = Vec::new();
1043 let mut buffer = [0_u8; 1024];
1044 while !request.windows(4).any(|window| window == b"\r\n\r\n") {
1045 let read = stream.read(&mut buffer).unwrap();
1046 if read == 0 {
1047 break;
1048 }
1049 request.extend_from_slice(&buffer[..read]);
1050 }
1051 let _ = request_tx.send(String::from_utf8(request).unwrap());
1052 stream.write_all(response.as_bytes()).unwrap();
1053 });
1054 (format!("http://{address}/releases"), request_rx)
1055 }
1056
1057 fn release(tag: &str, prerelease: bool, assets: &[(&str, &str)]) -> GhRelease {
1058 GhRelease {
1059 tag_name: tag.into(),
1060 html_url: format!("https://github.test/releases/tag/{tag}"),
1061 prerelease,
1062 draft: false,
1063 assets: assets
1064 .iter()
1065 .map(|(name, url)| GhAsset {
1066 name: (*name).into(),
1067 browser_download_url: (*url).into(),
1068 })
1069 .collect(),
1070 }
1071 }
1072
1073 fn valid_install_result() -> CheckResult {
1074 let current = Version::parse(current_version()).unwrap();
1075 let latest = Version::new(
1076 current.major,
1077 current.minor,
1078 current.patch.checked_add(1).unwrap(),
1079 );
1080 let tag = format!("v{latest}");
1081 let asset_name = current_asset_name().unwrap();
1082 CheckResult {
1083 current: current.to_string(),
1084 latest: latest.to_string(),
1085 tag: tag.clone(),
1086 newer: true,
1087 prerelease: false,
1088 release_url: format!("https://github.test/releases/tag/{tag}"),
1089 asset_url: release_asset_url(&tag, &asset_name),
1090 checksums_url: release_asset_url(&tag, CHECKSUMS_ASSET),
1091 asset_name,
1092 }
1093 }
1094
1095 #[test]
1096 fn normalizes_v_prefix() {
1097 assert_eq!(normalize_tag("v1.2.3"), "1.2.3");
1098 assert_eq!(normalize_tag(" 1.0.0 "), "1.0.0");
1099 }
1100
1101 #[test]
1102 fn compares_semver() {
1103 assert_eq!(is_newer("0.2.0", "0.1.0"), Some(true));
1104 assert_eq!(is_newer("0.1.0", "0.1.0"), Some(false));
1105 assert_eq!(is_newer("0.1.0", "0.2.0"), Some(false));
1106 assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true));
1107 assert_eq!(is_newer("0.1.1-rc.1", "0.1.0"), Some(true));
1108 }
1109
1110 #[test]
1111 fn conditional_release_request_reuses_etag_and_accepts_not_modified() {
1112 let (url, request) = serve_once(
1113 "HTTP/1.1 304 Not Modified\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1114 );
1115
1116 assert!(matches!(
1117 fetch_releases(&url, Some("\"release-etag\"")).unwrap(),
1118 ReleaseDocument::NotModified
1119 ));
1120 assert!(
1121 request
1122 .recv()
1123 .unwrap()
1124 .to_ascii_lowercase()
1125 .contains("if-none-match: \"release-etag\"")
1126 );
1127 }
1128
1129 #[test]
1130 fn modified_release_response_captures_the_new_etag() {
1131 let (url, _) = serve_once(
1132 "HTTP/1.1 200 OK\r\nETag: \"next-etag\"\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]",
1133 );
1134
1135 let ReleaseDocument::Modified { value: body, etag } = fetch_releases(&url, None).unwrap()
1136 else {
1137 panic!("a 200 response must carry a release document");
1138 };
1139 assert_eq!(body, "[]");
1140 assert_eq!(etag.as_deref(), Some("\"next-etag\""));
1141 }
1142
1143 #[test]
1144 fn rate_limited_release_request_preserves_retry_after() {
1145 let (url, _) = serve_once(
1146 "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 120\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1147 );
1148 let before = Utc::now().timestamp();
1149
1150 let error = fetch_releases(&url, None).expect_err("rate limit should fail the check");
1151
1152 assert_eq!(error.message, "GitHub API HTTP 429");
1153 assert!(error.retry_at.is_some_and(|retry_at| {
1154 retry_at >= before + 120 && retry_at <= Utc::now().timestamp() + 120
1155 }));
1156 }
1157
1158 #[test]
1159 fn retry_after_accepts_every_http_date_form() {
1160 let expected = 784_111_777;
1161 for value in [
1162 "Sun, 06 Nov 1994 08:49:37 GMT",
1163 "Sunday, 06-Nov-94 08:49:37 GMT",
1164 "Sun Nov 6 08:49:37 1994",
1165 ] {
1166 let (url, _) = serve_once(format!(
1167 "HTTP/1.1 429 Too Many Requests\r\nRetry-After: {value}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1168 ));
1169
1170 let error = fetch_releases(&url, None).expect_err("rate limit should fail the check");
1171
1172 assert_eq!(error.retry_at, Some(expected), "failed to parse {value}");
1173 }
1174 }
1175
1176 #[test]
1177 fn rate_limit_reset_is_used_only_when_the_budget_is_exhausted() {
1178 let reset = Utc::now().timestamp() + 3_600;
1179 let (url, _) = serve_once(format!(
1180 "HTTP/1.1 500 Internal Server Error\r\nX-RateLimit-Remaining: 1\r\nX-RateLimit-Reset: {reset}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1181 ));
1182 let error = fetch_releases(&url, None).expect_err("server error should fail the check");
1183 assert_eq!(error.retry_at, None);
1184
1185 let (url, _) = serve_once(format!(
1186 "HTTP/1.1 429 Too Many Requests\r\nX-RateLimit-Remaining: 0\r\nX-RateLimit-Reset: {reset}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1187 ));
1188 let error = fetch_releases(&url, None).expect_err("rate limit should fail the check");
1189 assert_eq!(error.retry_at, Some(reset));
1190 }
1191
1192 #[test]
1193 fn stable_release_tags_must_be_canonical_and_not_prereleases() {
1194 assert_eq!(parse_stable_tag("v1.2.3").unwrap().to_string(), "1.2.3");
1195 assert!(parse_stable_tag("1.2.3+build.4").is_none());
1196 assert!(parse_stable_tag("v01.2.3").is_none());
1197 assert!(parse_stable_tag("v1.2.3-rc.1").is_none());
1198 assert!(parse_stable_tag(" v1.2.3").is_none());
1199 }
1200
1201 #[test]
1202 fn install_hint_prefers_verified_self_update() {
1203 let r = CheckResult {
1204 current: "0.1.0".into(),
1205 latest: "0.1.0".into(),
1206 tag: "v0.1.0".into(),
1207 newer: false,
1208 prerelease: false,
1209 release_url: String::new(),
1210 asset_name: "mach-aarch64-apple-darwin".into(),
1211 asset_url: "https://example.test/mach".into(),
1212 checksums_url: "https://example.test/SHA256SUMS".into(),
1213 };
1214 let h = r.install_hint();
1215 assert!(h.contains("mach update --install"));
1216 assert!(h.contains("cargo install --locked mach-tui"));
1217 assert!(!h.contains("curl"));
1218 }
1219
1220 #[test]
1221 fn cargo_managed_binary_names_its_update_command() {
1222 let home = Path::new("/home/alice");
1223 let cargo_home = home.join(".cargo");
1224 let current_exe = cargo_home.join("bin/mach");
1225
1226 let error = resolve_install_destination(None, Some(home), Some(¤t_exe), None)
1227 .expect_err("a Cargo-managed executable must not create a shadow release install");
1228 assert_eq!(
1229 error,
1230 "Installation managed by Cargo: cargo install --locked mach-tui"
1231 );
1232
1233 assert_eq!(
1234 resolve_install_destination(
1235 Some(Path::new("/opt/mach/bin")),
1236 Some(home),
1237 Some(¤t_exe),
1238 None,
1239 )
1240 .unwrap(),
1241 PathBuf::from("/opt/mach/bin/mach"),
1242 "an explicit destination is an intentional ownership change"
1243 );
1244
1245 let custom_cargo_home = Path::new("/srv/cargo");
1246 let custom_exe = custom_cargo_home.join("bin/mach");
1247 assert!(
1248 resolve_install_destination(
1249 None,
1250 Some(home),
1251 Some(&custom_exe),
1252 Some(custom_cargo_home),
1253 )
1254 .is_err(),
1255 "CARGO_HOME must participate in ownership detection"
1256 );
1257 }
1258
1259 #[test]
1260 fn externally_managed_binary_does_not_create_a_shadow_release_install() {
1261 let home = Path::new("/home/alice");
1262 let current_exe = Path::new("/opt/homebrew/bin/mach");
1263
1264 let error = resolve_install_destination(None, Some(home), Some(current_exe), None)
1265 .expect_err("an externally managed executable must not update a shadow destination");
1266
1267 assert!(error.contains("package manager"));
1268 assert!(error.contains("MACH_INSTALL_DIR"));
1269 }
1270
1271 #[test]
1272 fn cargo_install_root_is_detected_from_its_ownership_metadata() {
1273 let dir = std::env::temp_dir().join(format!("mach-cargo-root-{}", uuid::Uuid::new_v4()));
1274 let cargo_root = dir.join("custom-cargo-root");
1275 let bin = cargo_root.join("bin");
1276 fs::create_dir_all(&bin).unwrap();
1277 fs::write(cargo_root.join(".crates2.json"), b"{}").unwrap();
1278 let current_exe = bin.join("mach");
1279
1280 let error =
1281 resolve_install_destination(None, Some(dir.as_path()), Some(¤t_exe), None)
1282 .expect_err(
1283 "cargo install --root ownership must not create a shadow release install",
1284 );
1285
1286 assert!(error.contains("Cargo"));
1287 fs::remove_dir_all(dir).unwrap();
1288 }
1289
1290 #[test]
1291 fn release_receipt_disambiguates_a_cargo_root_at_the_default_destination() {
1292 let dir = std::env::temp_dir().join(format!("mach-cargo-default-{}", uuid::Uuid::new_v4()));
1293 let home = dir.join("home");
1294 let cargo_root = home.join(".local");
1295 let bin = cargo_root.join("bin");
1296 fs::create_dir_all(&bin).unwrap();
1297 fs::write(cargo_root.join(".crates2.json"), b"{}").unwrap();
1298 let current_exe = bin.join("mach");
1299 let binary = b"ambiguous default-path binary";
1300 fs::write(¤t_exe, binary).unwrap();
1301
1302 let error = resolve_install_destination(None, Some(&home), Some(¤t_exe), None)
1303 .expect_err("Cargo ownership must beat an unreceipted default path");
1304 assert!(error.contains("Cargo"));
1305
1306 let receipt_dir = bin.join(RELEASE_RECEIPT_DIR);
1307 fs::create_dir(&receipt_dir).unwrap();
1308 fs::write(receipt_dir.join(sha256_hex(binary)), b"1.2.3\n").unwrap();
1309 assert_eq!(
1310 resolve_install_destination(None, Some(&home), Some(¤t_exe), None).unwrap(),
1311 current_exe,
1312 "a content-bound release receipt is stronger ownership evidence"
1313 );
1314
1315 fs::remove_dir_all(dir).unwrap();
1316 }
1317
1318 #[test]
1319 fn custom_release_destination_is_reused_only_with_a_matching_receipt() {
1320 let dir = std::env::temp_dir().join(format!("mach-release-root-{}", uuid::Uuid::new_v4()));
1321 let home = dir.join("home");
1322 let bin = dir.join("custom/bin");
1323 fs::create_dir_all(&bin).unwrap();
1324 let current_exe = bin.join("mach");
1325 let binary = b"checksum-verified release binary";
1326 fs::write(¤t_exe, binary).unwrap();
1327 let receipt_dir = bin.join(RELEASE_RECEIPT_DIR);
1328 fs::create_dir(&receipt_dir).unwrap();
1329 fs::write(receipt_dir.join(sha256_hex(binary)), b"1.2.3\n").unwrap();
1330
1331 assert_eq!(
1332 resolve_install_destination(None, Some(&home), Some(¤t_exe), None).unwrap(),
1333 current_exe
1334 );
1335 fs::remove_dir_all(dir).unwrap();
1336 }
1337
1338 #[test]
1339 fn selector_ignores_legacy_prereleases_and_binds_required_assets() {
1340 let releases = vec![
1341 release("v1.21.9", false, &[]),
1342 release(
1343 "v2.0.0-rc.1",
1344 false,
1345 &[
1346 ("mach-x86_64-unknown-linux-gnu", "https://bad/tagged-rc"),
1347 (CHECKSUMS_ASSET, "https://bad/tagged-rc-sums"),
1348 ],
1349 ),
1350 release(
1351 "v0.2.0-rc.1",
1352 true,
1353 &[
1354 ("mach-x86_64-unknown-linux-gnu", "https://bad/rc"),
1355 (CHECKSUMS_ASSET, "https://bad/rc-sums"),
1356 ],
1357 ),
1358 release(
1359 "v0.1.2",
1360 false,
1361 &[
1362 ("mach-x86_64-unknown-linux-gnu", "https://good/mach"),
1363 (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
1364 ],
1365 ),
1366 ];
1367
1368 let selected = select_release(&releases, "mach-x86_64-unknown-linux-gnu")
1369 .expect("stable release with both assets");
1370
1371 assert_eq!(selected.version.to_string(), "0.1.2");
1372 assert_eq!(selected.tag, "v0.1.2");
1373 assert_eq!(selected.asset_url, "https://good/mach");
1374 assert_eq!(selected.checksums_url, "https://good/SHA256SUMS");
1375 }
1376
1377 #[test]
1378 fn selector_allows_a_legitimate_major_upgrade() {
1379 let releases = vec![release(
1380 "v1.0.0",
1381 false,
1382 &[
1383 ("mach-aarch64-apple-darwin", "https://good/mach"),
1384 (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
1385 ],
1386 )];
1387
1388 let selected =
1389 select_release(&releases, "mach-aarch64-apple-darwin").expect("major upgrade");
1390 assert_eq!(selected.version.to_string(), "1.0.0");
1391 }
1392
1393 #[test]
1394 fn selector_still_returns_the_latest_release_when_this_build_is_ahead() {
1395 let releases = vec![release(
1396 "v0.9.0",
1397 false,
1398 &[
1399 ("mach-aarch64-apple-darwin", "https://good/mach"),
1400 (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
1401 ],
1402 )];
1403
1404 let selected = select_release(&releases, "mach-aarch64-apple-darwin")
1405 .expect("an older eligible release is still the latest published release");
1406 assert_eq!(selected.version.to_string(), "0.9.0");
1407 assert_eq!(
1408 is_newer(&selected.version.to_string(), "1.0.0"),
1409 Some(false)
1410 );
1411 }
1412
1413 #[test]
1414 fn selector_rejects_releases_missing_the_binary_or_checksum_manifest() {
1415 let releases = vec![
1416 release(
1417 "v0.3.0",
1418 false,
1419 &[(CHECKSUMS_ASSET, "https://bad/only-sums")],
1420 ),
1421 release(
1422 "v0.2.0",
1423 false,
1424 &[("mach-x86_64-unknown-linux-gnu", "https://bad/only-bin")],
1425 ),
1426 ];
1427
1428 assert!(select_release(&releases, "mach-x86_64-unknown-linux-gnu").is_none());
1429 }
1430
1431 #[test]
1432 fn checksum_parser_requires_one_exact_valid_asset_entry() {
1433 let digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1434 assert_eq!(
1435 checksum_for_asset(
1436 &format!("{digest} mach-aarch64-apple-darwin\n"),
1437 "mach-aarch64-apple-darwin",
1438 )
1439 .unwrap(),
1440 digest,
1441 );
1442 assert!(checksum_for_asset(&format!("{digest} mach-other\n"), "mach").is_err());
1443 assert!(checksum_for_asset(&format!("{digest} mach\n{digest} mach\n"), "mach",).is_err());
1444 assert!(checksum_for_asset(&format!("{digest} mach extra\n"), "mach").is_err());
1445 }
1446
1447 #[test]
1448 fn installer_rejects_urls_not_bound_to_the_selected_tag_and_asset() {
1449 let mut result = valid_install_result();
1450 validate_install_info(&result).unwrap();
1451
1452 result.asset_url.push_str("?wrong-release");
1453 assert!(validate_install_info(&result).is_err());
1454 }
1455
1456 #[test]
1457 fn installer_rejects_stale_or_non_update_check_results() {
1458 let mut stale = valid_install_result();
1459 stale.current = "0.0.0".into();
1460 assert!(
1461 validate_install_info(&stale)
1462 .unwrap_err()
1463 .contains("produced for")
1464 );
1465
1466 let mut not_newer = valid_install_result();
1467 not_newer.newer = false;
1468 assert!(
1469 validate_install_info(¬_newer)
1470 .unwrap_err()
1471 .contains("not newer")
1472 );
1473 }
1474
1475 #[test]
1476 fn installer_rejects_reinstalls_and_downgrades() {
1477 let mut reinstall = valid_install_result();
1478 reinstall.latest = current_version().into();
1479 reinstall.tag = format!("v{}", current_version());
1480 reinstall.asset_url = release_asset_url(&reinstall.tag, &reinstall.asset_name);
1481 reinstall.checksums_url = release_asset_url(&reinstall.tag, CHECKSUMS_ASSET);
1482 assert!(
1483 validate_install_info(&reinstall)
1484 .unwrap_err()
1485 .contains("must move forward")
1486 );
1487
1488 let current = Version::parse(current_version()).unwrap();
1489 let lower = Version::new(0, 0, 0);
1490 assert!(lower < current, "test package version must be above 0.0.0");
1491 let mut downgrade = valid_install_result();
1492 downgrade.latest = lower.to_string();
1493 downgrade.tag = format!("v{lower}");
1494 downgrade.asset_url = release_asset_url(&downgrade.tag, &downgrade.asset_name);
1495 downgrade.checksums_url = release_asset_url(&downgrade.tag, CHECKSUMS_ASSET);
1496 assert!(
1497 validate_install_info(&downgrade)
1498 .unwrap_err()
1499 .contains("must move forward")
1500 );
1501 }
1502
1503 #[test]
1504 fn text_responses_are_bounded() {
1505 assert_eq!(
1506 read_bounded_text(std::io::Cursor::new(b"four"), 4).unwrap(),
1507 "four"
1508 );
1509 assert!(
1510 read_bounded_text(std::io::Cursor::new(b"oversized"), 4)
1511 .unwrap_err()
1512 .contains("4-byte limit")
1513 );
1514 }
1515
1516 #[test]
1517 fn verified_replace_preserves_the_existing_binary_on_hash_failure() {
1518 let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1519 fs::create_dir(&dir).unwrap();
1520 let destination = dir.join("mach");
1521 fs::write(&destination, b"old binary").unwrap();
1522
1523 let error = write_verified_binary(
1524 std::io::Cursor::new(b"corrupt download"),
1525 &"0".repeat(64),
1526 &destination,
1527 &Version::parse("1.0.0").unwrap(),
1528 None,
1529 |_| {},
1530 )
1531 .unwrap_err();
1532
1533 assert!(error.contains("SHA-256"));
1534 assert_eq!(fs::read(&destination).unwrap(), b"old binary");
1535 fs::remove_dir_all(dir).unwrap();
1536 }
1537
1538 #[test]
1539 fn verified_replace_installs_an_executable_binary() {
1540 let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1541 fs::create_dir(&dir).unwrap();
1542 let destination = dir.join("mach");
1543 let binary = b"verified binary";
1544 let digest = sha256_hex(binary);
1545 let version = Version::parse("1.2.3").unwrap();
1546
1547 let (installed_version, disposition) = write_verified_binary(
1548 std::io::Cursor::new(binary),
1549 &digest,
1550 &destination,
1551 &version,
1552 None,
1553 |_| {},
1554 )
1555 .unwrap();
1556
1557 assert_eq!(installed_version, version);
1558 assert_eq!(disposition, InstallDisposition::Installed);
1559 assert_eq!(fs::read(&destination).unwrap(), binary);
1560 assert_eq!(
1561 fs::read_to_string(dir.join(RELEASE_RECEIPT_DIR).join(&digest)).unwrap(),
1562 "1.2.3\n"
1563 );
1564 #[cfg(unix)]
1565 {
1566 use std::os::unix::fs::PermissionsExt;
1567 assert_eq!(
1568 fs::metadata(&destination).unwrap().permissions().mode() & 0o777,
1569 0o755
1570 );
1571 }
1572 fs::remove_dir_all(dir).unwrap();
1573 }
1574
1575 #[test]
1576 fn verified_replace_does_not_downgrade_a_newer_receipted_binary() {
1577 let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1578 fs::create_dir(&dir).unwrap();
1579 let destination = dir.join("mach");
1580 let newer_binary = b"newer verified binary";
1581 fs::write(&destination, newer_binary).unwrap();
1582 let receipt_dir = dir.join(".mach-release-install");
1583 fs::create_dir(&receipt_dir).unwrap();
1584 fs::write(receipt_dir.join(sha256_hex(newer_binary)), b"9.9.9\n").unwrap();
1585
1586 let older_binary = b"older verified binary";
1587 let (installed_version, disposition) = write_verified_binary(
1588 std::io::Cursor::new(older_binary),
1589 &sha256_hex(older_binary),
1590 &destination,
1591 &Version::parse("9.8.7").unwrap(),
1592 None,
1593 |_| {},
1594 )
1595 .unwrap();
1596
1597 assert_eq!(installed_version, Version::parse("9.9.9").unwrap());
1598 assert_eq!(disposition, InstallDisposition::AlreadyCurrent);
1599 assert_eq!(fs::read(&destination).unwrap(), newer_binary);
1600 fs::remove_dir_all(dir).unwrap();
1601 }
1602
1603 #[test]
1604 fn failed_binary_rename_rolls_back_the_candidate_receipt() {
1605 let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1606 fs::create_dir(&dir).unwrap();
1607 let destination = dir.join("mach");
1608 fs::create_dir(&destination).unwrap();
1609 let binary = b"checksum-verified binary";
1610 let digest = sha256_hex(binary);
1611
1612 let error = write_verified_binary(
1613 std::io::Cursor::new(binary),
1614 &digest,
1615 &destination,
1616 &Version::parse("1.2.3").unwrap(),
1617 None,
1618 |_| {},
1619 )
1620 .unwrap_err();
1621
1622 assert!(error.contains("could not replace"));
1623 assert!(!dir.join(RELEASE_RECEIPT_DIR).join(digest).exists());
1624 fs::remove_dir_all(dir).unwrap();
1625 }
1626
1627 #[test]
1628 fn old_install_lock_owner_cannot_remove_a_new_lock() {
1629 let dir = std::env::temp_dir().join(format!("mach-update-lock-{}", uuid::Uuid::new_v4()));
1630 fs::create_dir(&dir).unwrap();
1631 let lock = InstallLock::acquire(&dir).unwrap();
1632 let lock_path = dir.join(INSTALL_LOCK_DIR);
1633 let replacement_record = "0 replacement-owner\n";
1634 fs::write(lock_path.join(INSTALL_LOCK_OWNER), replacement_record).unwrap();
1635
1636 drop(lock);
1637 assert_eq!(
1638 fs::read_to_string(lock_path.join(INSTALL_LOCK_OWNER)).unwrap(),
1639 replacement_record
1640 );
1641
1642 fs::remove_file(lock_path.join(INSTALL_LOCK_OWNER)).unwrap();
1643 fs::remove_dir(lock_path).unwrap();
1644 fs::remove_dir(dir).unwrap();
1645 }
1646
1647 #[test]
1648 fn install_lock_serializes_destination_writers() {
1649 let dir = std::env::temp_dir().join(format!("mach-update-lock-{}", uuid::Uuid::new_v4()));
1650 fs::create_dir(&dir).unwrap();
1651 let first = InstallLock::acquire(&dir).unwrap();
1652 let second_dir = dir.clone();
1653 let (acquired_tx, acquired_rx) = mpsc::channel();
1654 let waiter = std::thread::spawn(move || {
1655 let second = InstallLock::acquire(&second_dir).unwrap();
1656 acquired_tx.send(()).unwrap();
1657 drop(second);
1658 });
1659
1660 assert!(
1661 acquired_rx
1662 .recv_timeout(Duration::from_millis(250))
1663 .is_err(),
1664 "a second installer must wait while the destination lock is held"
1665 );
1666 drop(first);
1667 acquired_rx
1668 .recv_timeout(Duration::from_secs(2))
1669 .expect("the next installer should acquire the released lock");
1670 waiter.join().unwrap();
1671 fs::remove_dir(dir).unwrap();
1672 }
1673
1674 #[test]
1675 fn verified_replace_reports_monotonic_download_progress() {
1676 let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1677 fs::create_dir(&dir).unwrap();
1678 let destination = dir.join("mach");
1679 let binary = vec![b'x'; 150_000];
1680 let digest = sha256_hex(&binary);
1681 let mut progress = Vec::new();
1682
1683 write_verified_binary(
1684 std::io::Cursor::new(&binary),
1685 &digest,
1686 &destination,
1687 &Version::parse("1.2.3").unwrap(),
1688 Some(binary.len() as u64),
1689 |event| progress.push(event),
1690 )
1691 .unwrap();
1692
1693 assert_eq!(
1694 progress.first(),
1695 Some(&DownloadProgress {
1696 downloaded: 0,
1697 total: Some(binary.len() as u64),
1698 })
1699 );
1700 assert_eq!(
1701 progress.last(),
1702 Some(&DownloadProgress {
1703 downloaded: binary.len() as u64,
1704 total: Some(binary.len() as u64),
1705 })
1706 );
1707 assert!(
1708 progress
1709 .windows(2)
1710 .all(|pair| pair[0].downloaded <= pair[1].downloaded)
1711 );
1712 fs::remove_dir_all(dir).unwrap();
1713 }
1714}