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