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 mut response = download_response(url)?;
435 let total = response.body().content_length();
436 if total.is_some_and(|total| total > MAX_BINARY_BYTES) {
437 return Err(format!(
438 "release binary exceeds the {} MiB safety limit",
439 MAX_BINARY_BYTES / 1024 / 1024
440 ));
441 }
442 write_verified_binary(
443 response.body_mut().as_reader(),
444 expected_sha,
445 destination,
446 target_version,
447 total,
448 progress,
449 )
450}
451
452fn download_response(url: &str) -> Result<ureq::http::Response<ureq::Body>, String> {
453 let config = ureq::Agent::config_builder()
454 .timeout_global(Some(DOWNLOAD_TIMEOUT))
455 .build();
456 let agent: ureq::Agent = config.into();
457 agent
458 .get(url)
459 .header("User-Agent", USER_AGENT)
460 .header("Accept", "application/octet-stream")
461 .call()
462 .map_err(map_download_err)
463}
464
465fn write_verified_binary<R: Read>(
466 mut source: R,
467 expected_sha: &str,
468 destination: &Path,
469 target_version: &Version,
470 expected_total: Option<u64>,
471 mut progress: impl FnMut(DownloadProgress),
472) -> Result<(Version, InstallDisposition), String> {
473 #[cfg(not(unix))]
474 return Err("self-update is supported only on Unix platforms".into());
475
476 #[cfg(unix)]
477 {
478 if expected_sha.len() != 64 || !expected_sha.bytes().all(|byte| byte.is_ascii_hexdigit()) {
479 return Err("expected release digest is not a SHA-256 digest".into());
480 }
481 let expected_sha = expected_sha.to_ascii_lowercase();
482 let parent = destination
483 .parent()
484 .filter(|path| !path.as_os_str().is_empty())
485 .ok_or_else(|| "install destination has no parent directory".to_string())?;
486 fs::create_dir_all(parent).map_err(|e| {
487 format!(
488 "could not create install directory {}: {e}",
489 parent.display()
490 )
491 })?;
492 let parent_dir = File::open(parent)
493 .map_err(|e| format!("could not open install directory {}: {e}", parent.display()))?;
494 let temp_path = parent.join(format!(".mach.{}.tmp", uuid::Uuid::new_v4()));
495 let mut temp_file = OpenOptions::new()
496 .write(true)
497 .create_new(true)
498 .open(&temp_path)
499 .map_err(|e| format!("could not create temporary binary: {e}"))?;
500
501 progress(DownloadProgress {
502 downloaded: 0,
503 total: expected_total,
504 });
505
506 let write_result = (|| -> Result<String, String> {
507 let mut hasher = Sha256::new();
508 let mut downloaded = 0_u64;
509 let mut buffer = [0_u8; 64 * 1024];
510 loop {
511 let read = source
512 .read(&mut buffer)
513 .map_err(|e| format!("could not read release binary: {e}"))?;
514 if read == 0 {
515 break;
516 }
517 downloaded = downloaded
518 .checked_add(read as u64)
519 .ok_or_else(|| "release binary is too large".to_string())?;
520 if downloaded > MAX_BINARY_BYTES {
521 return Err(format!(
522 "release binary exceeds the {} MiB safety limit",
523 MAX_BINARY_BYTES / 1024 / 1024
524 ));
525 }
526 hasher.update(&buffer[..read]);
527 temp_file
528 .write_all(&buffer[..read])
529 .map_err(|e| format!("could not write temporary binary: {e}"))?;
530 progress(DownloadProgress {
531 downloaded,
532 total: expected_total,
533 });
534 }
535
536 let actual_sha = format!("{:x}", hasher.finalize());
537 if actual_sha != expected_sha {
538 return Err(format!(
539 "SHA-256 verification failed (expected {expected_sha}, got {actual_sha})"
540 ));
541 }
542 temp_file
543 .set_permissions(fs::Permissions::from_mode(0o755))
544 .map_err(|e| format!("could not mark temporary binary executable: {e}"))?;
545 temp_file
546 .sync_all()
547 .map_err(|e| format!("could not sync temporary binary: {e}"))?;
548 Ok(actual_sha)
549 })();
550 drop(temp_file);
551
552 let actual_sha = match write_result {
553 Ok(actual_sha) => actual_sha,
554 Err(error) => {
555 let _ = fs::remove_file(&temp_path);
556 return Err(error);
557 }
558 };
559
560 let install_result = (|| -> Result<(Version, InstallDisposition), String> {
561 let _lock = InstallLock::acquire(parent)?;
562 if let Some(installed_version) =
563 receipted_release_version(destination)?.filter(|version| version >= target_version)
564 {
565 return Ok((installed_version, InstallDisposition::AlreadyCurrent));
566 }
567
568 let (installed_version, receipt_update) =
569 record_release_version(parent, &actual_sha, target_version)?;
570 if let Err(error) = fs::rename(&temp_path, destination) {
571 let rollback_error = receipt_update.rollback().err();
572 let mut message = format!(
573 "could not replace {} atomically: {error}",
574 destination.display()
575 );
576 if let Some(rollback_error) = rollback_error {
577 message.push_str(&format!(
578 "; could not roll back release receipt: {rollback_error}"
579 ));
580 }
581 return Err(message);
582 }
583 parent_dir.sync_all().map_err(|e| {
584 format!("could not sync install directory {}: {e}", parent.display())
585 })?;
586 Ok((installed_version, InstallDisposition::Installed))
587 })();
588
589 if temp_path.exists() {
590 let _ = fs::remove_file(&temp_path);
591 }
592 install_result
593 }
594}
595
596#[cfg(unix)]
597fn receipted_release_version(destination: &Path) -> Result<Option<Version>, String> {
598 let Some(parent) = destination.parent() else {
599 return Ok(None);
600 };
601 let receipt_dir = parent.join(RELEASE_RECEIPT_DIR);
602 if !receipt_dir.is_dir() || !destination.is_file() {
603 return Ok(None);
604 }
605 let digest = sha256_file(destination)?;
606 let receipt = receipt_dir.join(digest);
607 if !receipt.is_file() {
608 return Ok(None);
609 }
610 read_receipt_version(&receipt).map(Some)
611}
612
613#[cfg(not(unix))]
614fn receipted_release_version(_destination: &Path) -> Result<Option<Version>, String> {
615 Ok(None)
616}
617
618#[cfg(unix)]
619fn sha256_file(path: &Path) -> Result<String, String> {
620 let metadata = fs::metadata(path)
621 .map_err(|e| format!("could not inspect installed binary {}: {e}", path.display()))?;
622 if metadata.len() > MAX_BINARY_BYTES {
623 return Err(format!(
624 "installed binary {} exceeds the {} MiB safety limit",
625 path.display(),
626 MAX_BINARY_BYTES / 1024 / 1024
627 ));
628 }
629 let mut file = File::open(path)
630 .map_err(|e| format!("could not open installed binary {}: {e}", path.display()))?;
631 let mut hasher = Sha256::new();
632 let mut buffer = [0_u8; 64 * 1024];
633 loop {
634 let read = file
635 .read(&mut buffer)
636 .map_err(|e| format!("could not read installed binary {}: {e}", path.display()))?;
637 if read == 0 {
638 break;
639 }
640 hasher.update(&buffer[..read]);
641 }
642 Ok(format!("{:x}", hasher.finalize()))
643}
644
645#[cfg(unix)]
646fn read_receipt_version(path: &Path) -> Result<Version, String> {
647 let file = File::open(path)
648 .map_err(|e| format!("could not open release receipt {}: {e}", path.display()))?;
649 let text = read_bounded_text(file, 128)
650 .map_err(|e| format!("invalid release receipt {}: {e}", path.display()))?;
651 let value = text
652 .strip_suffix('\n')
653 .filter(|value| !value.is_empty() && !value.contains(['\r', '\n']))
654 .ok_or_else(|| format!("invalid release receipt {}", path.display()))?;
655 let version = Version::parse(value)
656 .map_err(|e| format!("invalid release receipt {}: {e}", path.display()))?;
657 if !is_canonical_stable_version(value, &version) {
658 return Err(format!("invalid release receipt {}", path.display()));
659 }
660 Ok(version)
661}
662
663#[cfg(unix)]
664fn record_release_version(
665 parent: &Path,
666 digest: &str,
667 target_version: &Version,
668) -> Result<(Version, ReceiptUpdate), String> {
669 let receipt_dir = parent.join(RELEASE_RECEIPT_DIR);
670 fs::create_dir_all(&receipt_dir).map_err(|e| {
671 format!(
672 "could not create release receipt directory {}: {e}",
673 receipt_dir.display()
674 )
675 })?;
676 let receipt = receipt_dir.join(digest);
677 let previous_version = if receipt.is_file() {
678 let recorded = read_receipt_version(&receipt)?;
679 if recorded >= *target_version {
680 return Ok((recorded, ReceiptUpdate::Unchanged));
681 }
682 Some(recorded)
683 } else {
684 None
685 };
686
687 write_release_receipt(parent, &receipt, target_version)?;
688 let update = match previous_version {
689 Some(previous) => ReceiptUpdate::Replaced { receipt, previous },
690 None => ReceiptUpdate::Created(receipt),
691 };
692 Ok((target_version.clone(), update))
693}
694
695#[cfg(unix)]
696fn write_release_receipt(parent: &Path, receipt: &Path, version: &Version) -> Result<(), String> {
697 let receipt_dir = receipt
698 .parent()
699 .ok_or_else(|| "release receipt has no parent directory".to_string())?;
700 let temp_path = receipt_dir.join(format!(".receipt.{}.tmp", uuid::Uuid::new_v4()));
701 let write_result = (|| -> Result<(), String> {
702 let mut file = OpenOptions::new()
703 .write(true)
704 .create_new(true)
705 .open(&temp_path)
706 .map_err(|e| format!("could not create release receipt: {e}"))?;
707 writeln!(file, "{version}").map_err(|e| format!("could not write release receipt: {e}"))?;
708 file.sync_all()
709 .map_err(|e| format!("could not sync release receipt: {e}"))?;
710 fs::rename(&temp_path, receipt)
711 .map_err(|e| format!("could not publish release receipt: {e}"))?;
712 File::open(receipt_dir)
713 .and_then(|directory| directory.sync_all())
714 .map_err(|e| format!("could not sync release receipt directory: {e}"))?;
715 File::open(parent)
716 .and_then(|directory| directory.sync_all())
717 .map_err(|e| format!("could not sync install directory {}: {e}", parent.display()))?;
718 Ok(())
719 })();
720 if write_result.is_err() && temp_path.exists() {
721 let _ = fs::remove_file(&temp_path);
722 }
723 write_result
724}
725
726#[cfg(unix)]
727enum ReceiptUpdate {
728 Unchanged,
729 Created(PathBuf),
730 Replaced { receipt: PathBuf, previous: Version },
731}
732
733#[cfg(unix)]
734impl ReceiptUpdate {
735 fn rollback(self) -> Result<(), String> {
736 let receipt = match self {
737 Self::Unchanged => return Ok(()),
738 Self::Replaced { receipt, previous } => {
739 let parent = receipt
740 .parent()
741 .and_then(Path::parent)
742 .ok_or_else(|| "release receipt directory has no parent".to_string())?;
743 return write_release_receipt(parent, &receipt, &previous);
744 }
745 Self::Created(receipt) => receipt,
746 };
747 let receipt_dir = receipt
748 .parent()
749 .ok_or_else(|| "release receipt has no parent directory".to_string())?;
750 let parent = receipt_dir
751 .parent()
752 .ok_or_else(|| "release receipt directory has no parent".to_string())?;
753 fs::remove_file(&receipt)
754 .map_err(|e| format!("could not remove {}: {e}", receipt.display()))?;
755 File::open(receipt_dir)
756 .and_then(|directory| directory.sync_all())
757 .map_err(|e| format!("could not sync release receipt directory: {e}"))?;
758 File::open(parent)
759 .and_then(|directory| directory.sync_all())
760 .map_err(|e| format!("could not sync install directory {}: {e}", parent.display()))
761 }
762}
763
764#[cfg(unix)]
765struct InstallLock {
766 path: PathBuf,
767 owner_record: String,
768}
769
770#[cfg(unix)]
771impl InstallLock {
772 fn acquire(parent: &Path) -> Result<Self, String> {
773 let path = parent.join(INSTALL_LOCK_DIR);
774 let started = Instant::now();
775 loop {
776 match fs::create_dir(&path) {
777 Ok(()) => {
778 let timestamp = SystemTime::now()
779 .duration_since(UNIX_EPOCH)
780 .unwrap_or_default()
781 .as_secs();
782 let owner_record = format!("{timestamp} {}\n", uuid::Uuid::new_v4());
783 let owner_path = path.join(INSTALL_LOCK_OWNER);
784 let initialize = (|| -> Result<(), String> {
785 let mut owner = OpenOptions::new()
786 .write(true)
787 .create_new(true)
788 .open(&owner_path)
789 .map_err(|e| format!("could not create install lock owner: {e}"))?;
790 owner
791 .write_all(owner_record.as_bytes())
792 .map_err(|e| format!("could not write install lock owner: {e}"))?;
793 owner
794 .sync_all()
795 .map_err(|e| format!("could not sync install lock owner: {e}"))?;
796 File::open(&path)
797 .and_then(|directory| directory.sync_all())
798 .map_err(|e| format!("could not sync install lock: {e}"))?;
799 Ok(())
800 })();
801 if let Err(error) = initialize {
802 let _ = fs::remove_file(owner_path);
803 let _ = fs::remove_dir(&path);
804 return Err(error);
805 }
806 return Ok(Self { path, owner_record });
807 }
808 Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
809 Err(error) => {
810 return Err(format!(
811 "could not acquire install lock {}: {error}",
812 path.display()
813 ));
814 }
815 }
816 if started.elapsed() >= INSTALL_LOCK_WAIT {
817 return Err(format!(
818 "timed out waiting for another installer holding {}; if no installer is \
819 running, remove this stale lock directory",
820 path.display()
821 ));
822 }
823 thread::sleep(INSTALL_LOCK_POLL);
824 }
825 }
826}
827
828#[cfg(unix)]
829impl Drop for InstallLock {
830 fn drop(&mut self) {
831 let owner = self.path.join(INSTALL_LOCK_OWNER);
832 if fs::read_to_string(&owner).ok().as_deref() == Some(self.owner_record.as_str()) {
833 let _ = fs::remove_file(owner);
834 let _ = fs::remove_dir(&self.path);
835 }
836 }
837}
838
839#[cfg(test)]
840fn sha256_hex(bytes: &[u8]) -> String {
841 format!("{:x}", Sha256::digest(bytes))
842}
843
844#[derive(Debug, Deserialize)]
845struct GhRelease {
846 tag_name: String,
847 #[serde(default)]
848 html_url: String,
849 #[serde(default)]
850 prerelease: bool,
851 #[serde(default)]
852 draft: bool,
853 #[serde(default)]
854 assets: Vec<GhAsset>,
855}
856
857impl GhRelease {
858 fn asset_url(&self, name: &str) -> Option<&str> {
859 self.assets
860 .iter()
861 .find(|asset| asset.name == name && !asset.browser_download_url.is_empty())
862 .map(|asset| asset.browser_download_url.as_str())
863 }
864}
865
866#[derive(Debug, Deserialize)]
867struct GhAsset {
868 name: String,
869 #[serde(default)]
870 browser_download_url: String,
871}
872
873type ReleaseDocument = Conditional<String>;
874
875fn fetch_releases(url: &str, etag: Option<&str>) -> Result<ReleaseDocument, CheckFailure> {
876 let config = ureq::Agent::config_builder()
877 .timeout_global(Some(TIMEOUT))
878 .http_status_as_error(false)
879 .build();
880 let agent: ureq::Agent = config.into();
881 let mut request = agent
882 .get(url)
883 .header("User-Agent", USER_AGENT)
884 .header("Accept", "application/vnd.github+json");
885 if let Some(etag) = etag {
886 request = request.header("If-None-Match", etag);
887 }
888 let mut response = request
889 .call()
890 .map_err(|error| CheckFailure::new(map_ureq_err(error)))?;
891 let status = response.status().as_u16();
892 if status == 304 {
893 return Ok(ReleaseDocument::NotModified);
894 }
895 if status != 200 {
896 let now = Utc::now().timestamp();
897 let retry_at = response
898 .headers()
899 .get("Retry-After")
900 .and_then(|value| value.to_str().ok())
901 .and_then(|value| parse_retry_after(value, now))
902 .or_else(|| {
903 let remaining = response
904 .headers()
905 .get("X-RateLimit-Remaining")
906 .and_then(|value| value.to_str().ok());
907 (remaining == Some("0"))
908 .then(|| {
909 response
910 .headers()
911 .get("X-RateLimit-Reset")
912 .and_then(|value| value.to_str().ok())
913 .and_then(parse_nonnegative_decimal)
914 })
915 .flatten()
916 });
917 let message = if status == 404 {
918 "no GitHub releases yet — publish one, or install from git".into()
919 } else {
920 format!("GitHub API HTTP {status}")
921 };
922 return Err(CheckFailure { message, retry_at });
923 }
924 let response_etag = response
925 .headers()
926 .get("ETag")
927 .and_then(|value| value.to_str().ok())
928 .map(str::to_owned);
929 let body = read_bounded_text(response.body_mut().as_reader(), MAX_TEXT_BYTES)
930 .map_err(CheckFailure::new)?;
931 Ok(ReleaseDocument::Modified {
932 value: body,
933 etag: response_etag,
934 })
935}
936
937fn parse_retry_after(value: &str, now: i64) -> Option<i64> {
938 let value = value.trim();
939 if let Some(seconds) = parse_nonnegative_decimal(value) {
940 return Some(now.saturating_add(seconds));
941 }
942 let timestamp = httpdate::parse_http_date(value).ok()?;
943 let seconds = timestamp.duration_since(UNIX_EPOCH).ok()?.as_secs();
944 Some(i64::try_from(seconds).unwrap_or(i64::MAX))
945}
946
947fn parse_nonnegative_decimal(value: &str) -> Option<i64> {
948 let value = value.trim();
949 if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
950 return None;
951 }
952 Some(value.bytes().fold(0_i64, |number, byte| {
953 number
954 .saturating_mul(10)
955 .saturating_add(i64::from(byte - b'0'))
956 }))
957}
958
959fn download_checksum_manifest(url: &str) -> Result<String, String> {
960 let mut response = download_response(url)?;
961 read_bounded_text(response.body_mut().as_reader(), MAX_TEXT_BYTES)
962}
963
964fn read_bounded_text<R: Read>(source: R, max_bytes: u64) -> Result<String, String> {
965 let mut bytes = Vec::new();
966 source
967 .take(max_bytes.saturating_add(1))
968 .read_to_end(&mut bytes)
969 .map_err(|e| format!("could not read response: {e}"))?;
970 if bytes.len() as u64 > max_bytes {
971 return Err(format!("response exceeds the {max_bytes}-byte limit"));
972 }
973 String::from_utf8(bytes).map_err(|e| format!("response is not valid UTF-8: {e}"))
974}
975
976fn map_download_err(error: ureq::Error) -> String {
977 match error {
978 ureq::Error::StatusCode(code) => format!("download HTTP {code}"),
979 other => format!("download failed: {other}"),
980 }
981}
982
983fn parse_stable_tag(tag: &str) -> Option<Version> {
984 if tag != tag.trim() {
985 return None;
986 }
987 let tag = tag.trim();
988 let normalized = tag.strip_prefix('v').unwrap_or(tag);
989 parse_stable_version(normalized)
990}
991
992pub(crate) fn parse_stable_version(value: &str) -> Option<Version> {
993 let version = Version::parse(value).ok()?;
994 is_canonical_stable_version(value, &version).then_some(version)
995}
996
997fn is_canonical_stable_version(value: &str, version: &Version) -> bool {
998 version.pre.is_empty() && version.build.is_empty() && value == version.to_string()
999}
1000
1001fn map_ureq_err(e: ureq::Error) -> String {
1002 match e {
1003 ureq::Error::StatusCode(404) => {
1004 "no GitHub releases yet — publish one, or install from git".into()
1005 }
1006 ureq::Error::StatusCode(code) => format!("GitHub API HTTP {code}"),
1007 other => format!("network error: {other}"),
1008 }
1009}
1010
1011pub fn normalize_tag(tag: &str) -> String {
1013 let tag = tag.trim();
1014 tag.strip_prefix('v').unwrap_or(tag).to_string()
1015}
1016
1017pub fn is_newer(latest: &str, current: &str) -> Option<bool> {
1019 let a = Version::parse(&normalize_tag(latest)).ok()?;
1020 let b = Version::parse(&normalize_tag(current)).ok()?;
1021 Some(a > b)
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use super::*;
1027 use std::net::TcpListener;
1028 use std::sync::mpsc;
1029
1030 fn serve_once(response: impl Into<String>) -> (String, mpsc::Receiver<String>) {
1031 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1032 let address = listener.local_addr().unwrap();
1033 let (request_tx, request_rx) = mpsc::channel();
1034 let response = response.into();
1035 std::thread::spawn(move || {
1036 let (mut stream, _) = listener.accept().unwrap();
1037 let mut request = Vec::new();
1038 let mut buffer = [0_u8; 1024];
1039 while !request.windows(4).any(|window| window == b"\r\n\r\n") {
1040 let read = stream.read(&mut buffer).unwrap();
1041 if read == 0 {
1042 break;
1043 }
1044 request.extend_from_slice(&buffer[..read]);
1045 }
1046 let _ = request_tx.send(String::from_utf8(request).unwrap());
1047 stream.write_all(response.as_bytes()).unwrap();
1048 });
1049 (format!("http://{address}/releases"), request_rx)
1050 }
1051
1052 fn release(tag: &str, prerelease: bool, assets: &[(&str, &str)]) -> GhRelease {
1053 GhRelease {
1054 tag_name: tag.into(),
1055 html_url: format!("https://github.test/releases/tag/{tag}"),
1056 prerelease,
1057 draft: false,
1058 assets: assets
1059 .iter()
1060 .map(|(name, url)| GhAsset {
1061 name: (*name).into(),
1062 browser_download_url: (*url).into(),
1063 })
1064 .collect(),
1065 }
1066 }
1067
1068 fn valid_install_result() -> CheckResult {
1069 let current = Version::parse(current_version()).unwrap();
1070 let latest = Version::new(
1071 current.major,
1072 current.minor,
1073 current.patch.checked_add(1).unwrap(),
1074 );
1075 let tag = format!("v{latest}");
1076 let asset_name = current_asset_name().unwrap();
1077 CheckResult {
1078 current: current.to_string(),
1079 latest: latest.to_string(),
1080 tag: tag.clone(),
1081 newer: true,
1082 prerelease: false,
1083 release_url: format!("https://github.test/releases/tag/{tag}"),
1084 asset_url: release_asset_url(&tag, &asset_name),
1085 checksums_url: release_asset_url(&tag, CHECKSUMS_ASSET),
1086 asset_name,
1087 }
1088 }
1089
1090 #[test]
1091 fn normalizes_v_prefix() {
1092 assert_eq!(normalize_tag("v1.2.3"), "1.2.3");
1093 assert_eq!(normalize_tag(" 1.0.0 "), "1.0.0");
1094 }
1095
1096 #[test]
1097 fn compares_semver() {
1098 assert_eq!(is_newer("0.2.0", "0.1.0"), Some(true));
1099 assert_eq!(is_newer("0.1.0", "0.1.0"), Some(false));
1100 assert_eq!(is_newer("0.1.0", "0.2.0"), Some(false));
1101 assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true));
1102 assert_eq!(is_newer("0.1.1-rc.1", "0.1.0"), Some(true));
1103 }
1104
1105 #[test]
1106 fn conditional_release_request_reuses_etag_and_accepts_not_modified() {
1107 let (url, request) = serve_once(
1108 "HTTP/1.1 304 Not Modified\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1109 );
1110
1111 assert!(matches!(
1112 fetch_releases(&url, Some("\"release-etag\"")).unwrap(),
1113 ReleaseDocument::NotModified
1114 ));
1115 assert!(
1116 request
1117 .recv()
1118 .unwrap()
1119 .to_ascii_lowercase()
1120 .contains("if-none-match: \"release-etag\"")
1121 );
1122 }
1123
1124 #[test]
1125 fn modified_release_response_captures_the_new_etag() {
1126 let (url, _) = serve_once(
1127 "HTTP/1.1 200 OK\r\nETag: \"next-etag\"\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]",
1128 );
1129
1130 let ReleaseDocument::Modified { value: body, etag } = fetch_releases(&url, None).unwrap()
1131 else {
1132 panic!("a 200 response must carry a release document");
1133 };
1134 assert_eq!(body, "[]");
1135 assert_eq!(etag.as_deref(), Some("\"next-etag\""));
1136 }
1137
1138 #[test]
1139 fn rate_limited_release_request_preserves_retry_after() {
1140 let (url, _) = serve_once(
1141 "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 120\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1142 );
1143 let before = Utc::now().timestamp();
1144
1145 let error = fetch_releases(&url, None).expect_err("rate limit should fail the check");
1146
1147 assert_eq!(error.message, "GitHub API HTTP 429");
1148 assert!(error.retry_at.is_some_and(|retry_at| {
1149 retry_at >= before + 120 && retry_at <= Utc::now().timestamp() + 120
1150 }));
1151 }
1152
1153 #[test]
1154 fn retry_after_accepts_every_http_date_form() {
1155 let expected = 784_111_777;
1156 for value in [
1157 "Sun, 06 Nov 1994 08:49:37 GMT",
1158 "Sunday, 06-Nov-94 08:49:37 GMT",
1159 "Sun Nov 6 08:49:37 1994",
1160 ] {
1161 let (url, _) = serve_once(format!(
1162 "HTTP/1.1 429 Too Many Requests\r\nRetry-After: {value}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1163 ));
1164
1165 let error = fetch_releases(&url, None).expect_err("rate limit should fail the check");
1166
1167 assert_eq!(error.retry_at, Some(expected), "failed to parse {value}");
1168 }
1169 }
1170
1171 #[test]
1172 fn rate_limit_reset_is_used_only_when_the_budget_is_exhausted() {
1173 let reset = Utc::now().timestamp() + 3_600;
1174 let (url, _) = serve_once(format!(
1175 "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"
1176 ));
1177 let error = fetch_releases(&url, None).expect_err("server error should fail the check");
1178 assert_eq!(error.retry_at, None);
1179
1180 let (url, _) = serve_once(format!(
1181 "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"
1182 ));
1183 let error = fetch_releases(&url, None).expect_err("rate limit should fail the check");
1184 assert_eq!(error.retry_at, Some(reset));
1185 }
1186
1187 #[test]
1188 fn stable_release_tags_must_be_canonical_and_not_prereleases() {
1189 assert_eq!(parse_stable_tag("v1.2.3").unwrap().to_string(), "1.2.3");
1190 assert!(parse_stable_tag("1.2.3+build.4").is_none());
1191 assert!(parse_stable_tag("v01.2.3").is_none());
1192 assert!(parse_stable_tag("v1.2.3-rc.1").is_none());
1193 assert!(parse_stable_tag(" v1.2.3").is_none());
1194 }
1195
1196 #[test]
1197 fn install_hint_prefers_verified_self_update() {
1198 let r = CheckResult {
1199 current: "0.1.0".into(),
1200 latest: "0.1.0".into(),
1201 tag: "v0.1.0".into(),
1202 newer: false,
1203 prerelease: false,
1204 release_url: String::new(),
1205 asset_name: "mach-aarch64-apple-darwin".into(),
1206 asset_url: "https://example.test/mach".into(),
1207 checksums_url: "https://example.test/SHA256SUMS".into(),
1208 };
1209 let h = r.install_hint();
1210 assert!(h.contains("mach update --install"));
1211 assert!(h.contains("cargo install --locked mach-tui"));
1212 assert!(!h.contains("curl"));
1213 }
1214
1215 #[test]
1216 fn cargo_managed_binary_names_its_update_command() {
1217 let home = Path::new("/home/alice");
1218 let cargo_home = home.join(".cargo");
1219 let current_exe = cargo_home.join("bin/mach");
1220
1221 let error = resolve_install_destination(None, Some(home), Some(¤t_exe), None)
1222 .expect_err("a Cargo-managed executable must not create a shadow release install");
1223 assert_eq!(
1224 error,
1225 "Installation managed by Cargo: cargo install --locked mach-tui"
1226 );
1227
1228 assert_eq!(
1229 resolve_install_destination(
1230 Some(Path::new("/opt/mach/bin")),
1231 Some(home),
1232 Some(¤t_exe),
1233 None,
1234 )
1235 .unwrap(),
1236 PathBuf::from("/opt/mach/bin/mach"),
1237 "an explicit destination is an intentional ownership change"
1238 );
1239
1240 let custom_cargo_home = Path::new("/srv/cargo");
1241 let custom_exe = custom_cargo_home.join("bin/mach");
1242 assert!(
1243 resolve_install_destination(
1244 None,
1245 Some(home),
1246 Some(&custom_exe),
1247 Some(custom_cargo_home),
1248 )
1249 .is_err(),
1250 "CARGO_HOME must participate in ownership detection"
1251 );
1252 }
1253
1254 #[test]
1255 fn externally_managed_binary_does_not_create_a_shadow_release_install() {
1256 let home = Path::new("/home/alice");
1257 let current_exe = Path::new("/opt/homebrew/bin/mach");
1258
1259 let error = resolve_install_destination(None, Some(home), Some(current_exe), None)
1260 .expect_err("an externally managed executable must not update a shadow destination");
1261
1262 assert!(error.contains("package manager"));
1263 assert!(error.contains("MACH_INSTALL_DIR"));
1264 }
1265
1266 #[test]
1267 fn cargo_install_root_is_detected_from_its_ownership_metadata() {
1268 let dir = std::env::temp_dir().join(format!("mach-cargo-root-{}", uuid::Uuid::new_v4()));
1269 let cargo_root = dir.join("custom-cargo-root");
1270 let bin = cargo_root.join("bin");
1271 fs::create_dir_all(&bin).unwrap();
1272 fs::write(cargo_root.join(".crates2.json"), b"{}").unwrap();
1273 let current_exe = bin.join("mach");
1274
1275 let error =
1276 resolve_install_destination(None, Some(dir.as_path()), Some(¤t_exe), None)
1277 .expect_err(
1278 "cargo install --root ownership must not create a shadow release install",
1279 );
1280
1281 assert!(error.contains("Cargo"));
1282 fs::remove_dir_all(dir).unwrap();
1283 }
1284
1285 #[test]
1286 fn release_receipt_disambiguates_a_cargo_root_at_the_default_destination() {
1287 let dir = std::env::temp_dir().join(format!("mach-cargo-default-{}", uuid::Uuid::new_v4()));
1288 let home = dir.join("home");
1289 let cargo_root = home.join(".local");
1290 let bin = cargo_root.join("bin");
1291 fs::create_dir_all(&bin).unwrap();
1292 fs::write(cargo_root.join(".crates2.json"), b"{}").unwrap();
1293 let current_exe = bin.join("mach");
1294 let binary = b"ambiguous default-path binary";
1295 fs::write(¤t_exe, binary).unwrap();
1296
1297 let error = resolve_install_destination(None, Some(&home), Some(¤t_exe), None)
1298 .expect_err("Cargo ownership must beat an unreceipted default path");
1299 assert!(error.contains("Cargo"));
1300
1301 let receipt_dir = bin.join(RELEASE_RECEIPT_DIR);
1302 fs::create_dir(&receipt_dir).unwrap();
1303 fs::write(receipt_dir.join(sha256_hex(binary)), b"1.2.3\n").unwrap();
1304 assert_eq!(
1305 resolve_install_destination(None, Some(&home), Some(¤t_exe), None).unwrap(),
1306 current_exe,
1307 "a content-bound release receipt is stronger ownership evidence"
1308 );
1309
1310 fs::remove_dir_all(dir).unwrap();
1311 }
1312
1313 #[test]
1314 fn custom_release_destination_is_reused_only_with_a_matching_receipt() {
1315 let dir = std::env::temp_dir().join(format!("mach-release-root-{}", uuid::Uuid::new_v4()));
1316 let home = dir.join("home");
1317 let bin = dir.join("custom/bin");
1318 fs::create_dir_all(&bin).unwrap();
1319 let current_exe = bin.join("mach");
1320 let binary = b"checksum-verified release binary";
1321 fs::write(¤t_exe, binary).unwrap();
1322 let receipt_dir = bin.join(RELEASE_RECEIPT_DIR);
1323 fs::create_dir(&receipt_dir).unwrap();
1324 fs::write(receipt_dir.join(sha256_hex(binary)), b"1.2.3\n").unwrap();
1325
1326 assert_eq!(
1327 resolve_install_destination(None, Some(&home), Some(¤t_exe), None).unwrap(),
1328 current_exe
1329 );
1330 fs::remove_dir_all(dir).unwrap();
1331 }
1332
1333 #[test]
1334 fn selector_ignores_legacy_prereleases_and_binds_required_assets() {
1335 let releases = vec![
1336 release("v1.21.9", false, &[]),
1337 release(
1338 "v2.0.0-rc.1",
1339 false,
1340 &[
1341 ("mach-x86_64-unknown-linux-gnu", "https://bad/tagged-rc"),
1342 (CHECKSUMS_ASSET, "https://bad/tagged-rc-sums"),
1343 ],
1344 ),
1345 release(
1346 "v0.2.0-rc.1",
1347 true,
1348 &[
1349 ("mach-x86_64-unknown-linux-gnu", "https://bad/rc"),
1350 (CHECKSUMS_ASSET, "https://bad/rc-sums"),
1351 ],
1352 ),
1353 release(
1354 "v0.1.2",
1355 false,
1356 &[
1357 ("mach-x86_64-unknown-linux-gnu", "https://good/mach"),
1358 (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
1359 ],
1360 ),
1361 ];
1362
1363 let selected = select_release(&releases, "mach-x86_64-unknown-linux-gnu")
1364 .expect("stable release with both assets");
1365
1366 assert_eq!(selected.version.to_string(), "0.1.2");
1367 assert_eq!(selected.tag, "v0.1.2");
1368 assert_eq!(selected.asset_url, "https://good/mach");
1369 assert_eq!(selected.checksums_url, "https://good/SHA256SUMS");
1370 }
1371
1372 #[test]
1373 fn selector_allows_a_legitimate_major_upgrade() {
1374 let releases = vec![release(
1375 "v1.0.0",
1376 false,
1377 &[
1378 ("mach-aarch64-apple-darwin", "https://good/mach"),
1379 (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
1380 ],
1381 )];
1382
1383 let selected =
1384 select_release(&releases, "mach-aarch64-apple-darwin").expect("major upgrade");
1385 assert_eq!(selected.version.to_string(), "1.0.0");
1386 }
1387
1388 #[test]
1389 fn selector_still_returns_the_latest_release_when_this_build_is_ahead() {
1390 let releases = vec![release(
1391 "v0.9.0",
1392 false,
1393 &[
1394 ("mach-aarch64-apple-darwin", "https://good/mach"),
1395 (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
1396 ],
1397 )];
1398
1399 let selected = select_release(&releases, "mach-aarch64-apple-darwin")
1400 .expect("an older eligible release is still the latest published release");
1401 assert_eq!(selected.version.to_string(), "0.9.0");
1402 assert_eq!(
1403 is_newer(&selected.version.to_string(), "1.0.0"),
1404 Some(false)
1405 );
1406 }
1407
1408 #[test]
1409 fn selector_rejects_releases_missing_the_binary_or_checksum_manifest() {
1410 let releases = vec![
1411 release(
1412 "v0.3.0",
1413 false,
1414 &[(CHECKSUMS_ASSET, "https://bad/only-sums")],
1415 ),
1416 release(
1417 "v0.2.0",
1418 false,
1419 &[("mach-x86_64-unknown-linux-gnu", "https://bad/only-bin")],
1420 ),
1421 ];
1422
1423 assert!(select_release(&releases, "mach-x86_64-unknown-linux-gnu").is_none());
1424 }
1425
1426 #[test]
1427 fn checksum_parser_requires_one_exact_valid_asset_entry() {
1428 let digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1429 assert_eq!(
1430 checksum_for_asset(
1431 &format!("{digest} mach-aarch64-apple-darwin\n"),
1432 "mach-aarch64-apple-darwin",
1433 )
1434 .unwrap(),
1435 digest,
1436 );
1437 assert!(checksum_for_asset(&format!("{digest} mach-other\n"), "mach").is_err());
1438 assert!(checksum_for_asset(&format!("{digest} mach\n{digest} mach\n"), "mach",).is_err());
1439 assert!(checksum_for_asset(&format!("{digest} mach extra\n"), "mach").is_err());
1440 }
1441
1442 #[test]
1443 fn installer_rejects_urls_not_bound_to_the_selected_tag_and_asset() {
1444 let mut result = valid_install_result();
1445 validate_install_info(&result).unwrap();
1446
1447 result.asset_url.push_str("?wrong-release");
1448 assert!(validate_install_info(&result).is_err());
1449 }
1450
1451 #[test]
1452 fn installer_rejects_stale_or_non_update_check_results() {
1453 let mut stale = valid_install_result();
1454 stale.current = "0.0.0".into();
1455 assert!(
1456 validate_install_info(&stale)
1457 .unwrap_err()
1458 .contains("produced for")
1459 );
1460
1461 let mut not_newer = valid_install_result();
1462 not_newer.newer = false;
1463 assert!(
1464 validate_install_info(¬_newer)
1465 .unwrap_err()
1466 .contains("not newer")
1467 );
1468 }
1469
1470 #[test]
1471 fn installer_rejects_reinstalls_and_downgrades() {
1472 let mut reinstall = valid_install_result();
1473 reinstall.latest = current_version().into();
1474 reinstall.tag = format!("v{}", current_version());
1475 reinstall.asset_url = release_asset_url(&reinstall.tag, &reinstall.asset_name);
1476 reinstall.checksums_url = release_asset_url(&reinstall.tag, CHECKSUMS_ASSET);
1477 assert!(
1478 validate_install_info(&reinstall)
1479 .unwrap_err()
1480 .contains("must move forward")
1481 );
1482
1483 let current = Version::parse(current_version()).unwrap();
1484 let lower = Version::new(0, 0, 0);
1485 assert!(lower < current, "test package version must be above 0.0.0");
1486 let mut downgrade = valid_install_result();
1487 downgrade.latest = lower.to_string();
1488 downgrade.tag = format!("v{lower}");
1489 downgrade.asset_url = release_asset_url(&downgrade.tag, &downgrade.asset_name);
1490 downgrade.checksums_url = release_asset_url(&downgrade.tag, CHECKSUMS_ASSET);
1491 assert!(
1492 validate_install_info(&downgrade)
1493 .unwrap_err()
1494 .contains("must move forward")
1495 );
1496 }
1497
1498 #[test]
1499 fn text_responses_are_bounded() {
1500 assert_eq!(
1501 read_bounded_text(std::io::Cursor::new(b"four"), 4).unwrap(),
1502 "four"
1503 );
1504 assert!(
1505 read_bounded_text(std::io::Cursor::new(b"oversized"), 4)
1506 .unwrap_err()
1507 .contains("4-byte limit")
1508 );
1509 }
1510
1511 #[test]
1512 fn verified_replace_preserves_the_existing_binary_on_hash_failure() {
1513 let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1514 fs::create_dir(&dir).unwrap();
1515 let destination = dir.join("mach");
1516 fs::write(&destination, b"old binary").unwrap();
1517
1518 let error = write_verified_binary(
1519 std::io::Cursor::new(b"corrupt download"),
1520 &"0".repeat(64),
1521 &destination,
1522 &Version::parse("1.0.0").unwrap(),
1523 None,
1524 |_| {},
1525 )
1526 .unwrap_err();
1527
1528 assert!(error.contains("SHA-256"));
1529 assert_eq!(fs::read(&destination).unwrap(), b"old binary");
1530 fs::remove_dir_all(dir).unwrap();
1531 }
1532
1533 #[test]
1534 fn verified_replace_installs_an_executable_binary() {
1535 let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1536 fs::create_dir(&dir).unwrap();
1537 let destination = dir.join("mach");
1538 let binary = b"verified binary";
1539 let digest = sha256_hex(binary);
1540 let version = Version::parse("1.2.3").unwrap();
1541
1542 let (installed_version, disposition) = write_verified_binary(
1543 std::io::Cursor::new(binary),
1544 &digest,
1545 &destination,
1546 &version,
1547 None,
1548 |_| {},
1549 )
1550 .unwrap();
1551
1552 assert_eq!(installed_version, version);
1553 assert_eq!(disposition, InstallDisposition::Installed);
1554 assert_eq!(fs::read(&destination).unwrap(), binary);
1555 assert_eq!(
1556 fs::read_to_string(dir.join(RELEASE_RECEIPT_DIR).join(&digest)).unwrap(),
1557 "1.2.3\n"
1558 );
1559 #[cfg(unix)]
1560 {
1561 use std::os::unix::fs::PermissionsExt;
1562 assert_eq!(
1563 fs::metadata(&destination).unwrap().permissions().mode() & 0o777,
1564 0o755
1565 );
1566 }
1567 fs::remove_dir_all(dir).unwrap();
1568 }
1569
1570 #[test]
1571 fn verified_replace_does_not_downgrade_a_newer_receipted_binary() {
1572 let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1573 fs::create_dir(&dir).unwrap();
1574 let destination = dir.join("mach");
1575 let newer_binary = b"newer verified binary";
1576 fs::write(&destination, newer_binary).unwrap();
1577 let receipt_dir = dir.join(".mach-release-install");
1578 fs::create_dir(&receipt_dir).unwrap();
1579 fs::write(receipt_dir.join(sha256_hex(newer_binary)), b"9.9.9\n").unwrap();
1580
1581 let older_binary = b"older verified binary";
1582 let (installed_version, disposition) = write_verified_binary(
1583 std::io::Cursor::new(older_binary),
1584 &sha256_hex(older_binary),
1585 &destination,
1586 &Version::parse("9.8.7").unwrap(),
1587 None,
1588 |_| {},
1589 )
1590 .unwrap();
1591
1592 assert_eq!(installed_version, Version::parse("9.9.9").unwrap());
1593 assert_eq!(disposition, InstallDisposition::AlreadyCurrent);
1594 assert_eq!(fs::read(&destination).unwrap(), newer_binary);
1595 fs::remove_dir_all(dir).unwrap();
1596 }
1597
1598 #[test]
1599 fn failed_binary_rename_rolls_back_the_candidate_receipt() {
1600 let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1601 fs::create_dir(&dir).unwrap();
1602 let destination = dir.join("mach");
1603 fs::create_dir(&destination).unwrap();
1604 let binary = b"checksum-verified binary";
1605 let digest = sha256_hex(binary);
1606
1607 let error = write_verified_binary(
1608 std::io::Cursor::new(binary),
1609 &digest,
1610 &destination,
1611 &Version::parse("1.2.3").unwrap(),
1612 None,
1613 |_| {},
1614 )
1615 .unwrap_err();
1616
1617 assert!(error.contains("could not replace"));
1618 assert!(!dir.join(RELEASE_RECEIPT_DIR).join(digest).exists());
1619 fs::remove_dir_all(dir).unwrap();
1620 }
1621
1622 #[test]
1623 fn old_install_lock_owner_cannot_remove_a_new_lock() {
1624 let dir = std::env::temp_dir().join(format!("mach-update-lock-{}", uuid::Uuid::new_v4()));
1625 fs::create_dir(&dir).unwrap();
1626 let lock = InstallLock::acquire(&dir).unwrap();
1627 let lock_path = dir.join(INSTALL_LOCK_DIR);
1628 let replacement_record = "0 replacement-owner\n";
1629 fs::write(lock_path.join(INSTALL_LOCK_OWNER), replacement_record).unwrap();
1630
1631 drop(lock);
1632 assert_eq!(
1633 fs::read_to_string(lock_path.join(INSTALL_LOCK_OWNER)).unwrap(),
1634 replacement_record
1635 );
1636
1637 fs::remove_file(lock_path.join(INSTALL_LOCK_OWNER)).unwrap();
1638 fs::remove_dir(lock_path).unwrap();
1639 fs::remove_dir(dir).unwrap();
1640 }
1641
1642 #[test]
1643 fn install_lock_serializes_destination_writers() {
1644 let dir = std::env::temp_dir().join(format!("mach-update-lock-{}", uuid::Uuid::new_v4()));
1645 fs::create_dir(&dir).unwrap();
1646 let first = InstallLock::acquire(&dir).unwrap();
1647 let second_dir = dir.clone();
1648 let (acquired_tx, acquired_rx) = mpsc::channel();
1649 let waiter = std::thread::spawn(move || {
1650 let second = InstallLock::acquire(&second_dir).unwrap();
1651 acquired_tx.send(()).unwrap();
1652 drop(second);
1653 });
1654
1655 assert!(
1656 acquired_rx
1657 .recv_timeout(Duration::from_millis(250))
1658 .is_err(),
1659 "a second installer must wait while the destination lock is held"
1660 );
1661 drop(first);
1662 acquired_rx
1663 .recv_timeout(Duration::from_secs(2))
1664 .expect("the next installer should acquire the released lock");
1665 waiter.join().unwrap();
1666 fs::remove_dir(dir).unwrap();
1667 }
1668
1669 #[test]
1670 fn verified_replace_reports_monotonic_download_progress() {
1671 let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1672 fs::create_dir(&dir).unwrap();
1673 let destination = dir.join("mach");
1674 let binary = vec![b'x'; 150_000];
1675 let digest = sha256_hex(&binary);
1676 let mut progress = Vec::new();
1677
1678 write_verified_binary(
1679 std::io::Cursor::new(&binary),
1680 &digest,
1681 &destination,
1682 &Version::parse("1.2.3").unwrap(),
1683 Some(binary.len() as u64),
1684 |event| progress.push(event),
1685 )
1686 .unwrap();
1687
1688 assert_eq!(
1689 progress.first(),
1690 Some(&DownloadProgress {
1691 downloaded: 0,
1692 total: Some(binary.len() as u64),
1693 })
1694 );
1695 assert_eq!(
1696 progress.last(),
1697 Some(&DownloadProgress {
1698 downloaded: binary.len() as u64,
1699 total: Some(binary.len() as u64),
1700 })
1701 );
1702 assert!(
1703 progress
1704 .windows(2)
1705 .all(|pair| pair[0].downloaded <= pair[1].downloaded)
1706 );
1707 fs::remove_dir_all(dir).unwrap();
1708 }
1709}