1use std::fs::{self, File, OpenOptions};
10use std::io::{Read, Write};
11use std::path::{Path, PathBuf};
12use std::time::{Duration, UNIX_EPOCH};
13
14use chrono::Utc;
15use semver::Version;
16use serde::Deserialize;
17use sha2::{Digest, Sha256};
18
19#[cfg(unix)]
20use std::os::unix::fs::PermissionsExt;
21
22pub const REPO: &str = "Q1CHENL/mach";
24pub const GIT_URL: &str = "https://github.com/Q1CHENL/mach";
25const RELEASES_URL: &str = "https://api.github.com/repos/Q1CHENL/mach/releases?per_page=100";
26const RELEASE_DOWNLOAD_BASE: &str = "https://github.com/Q1CHENL/mach/releases/download";
27const CHECKSUMS_ASSET: &str = "SHA256SUMS";
28const USER_AGENT: &str = concat!("mach/", env!("CARGO_PKG_VERSION"));
29const TIMEOUT: Duration = Duration::from_secs(8);
30const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120);
31const MAX_TEXT_BYTES: u64 = 1024 * 1024;
32const MAX_BINARY_BYTES: u64 = 128 * 1024 * 1024;
33
34#[derive(Debug, Clone)]
35pub struct CheckResult {
36 pub current: String,
37 pub latest: String,
38 pub tag: String,
40 pub newer: bool,
41 pub prerelease: bool,
42 pub release_url: String,
43 pub asset_name: String,
45 pub asset_url: String,
46 pub checksums_url: String,
47}
48
49#[derive(Debug)]
50pub(crate) enum CheckResponse {
51 Modified {
52 info: CheckResult,
53 etag: Option<String>,
54 },
55 NotModified,
56}
57
58#[derive(Debug)]
59pub(crate) struct CheckFailure {
60 pub(crate) message: String,
61 pub(crate) retry_at: Option<i64>,
62}
63
64impl CheckFailure {
65 fn new(message: impl Into<String>) -> Self {
66 Self {
67 message: message.into(),
68 retry_at: None,
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct InstallResult {
75 pub destination: PathBuf,
76 pub tag: String,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub(crate) struct DownloadProgress {
81 pub(crate) downloaded: u64,
82 pub(crate) total: Option<u64>,
83}
84
85impl CheckResult {
86 pub fn summary(&self) -> String {
88 if self.newer {
89 format!(
90 "Update available: v{} → v{} ({})",
91 self.current, self.latest, self.release_url
92 )
93 } else {
94 format!("Up to date (v{})", self.current)
95 }
96 }
97
98 pub fn install_hint(&self) -> String {
100 "mach update --install\n# or, for Cargo installs: cargo install --locked mach-tui".into()
101 }
102}
103
104pub fn current_version() -> &'static str {
106 crate::VERSION
107}
108
109pub fn check() -> Result<CheckResult, String> {
115 match check_with_etag(None).map_err(|error| error.message)? {
116 CheckResponse::Modified { info, .. } => Ok(info),
117 CheckResponse::NotModified => {
118 Err("GitHub returned 304 without a conditional request".into())
119 }
120 }
121}
122
123pub(crate) fn check_with_etag(etag: Option<&str>) -> Result<CheckResponse, CheckFailure> {
125 let current = current_version().to_string();
126 let ReleaseDocument::Modified { body, etag } = fetch_releases(RELEASES_URL, etag)? else {
127 return Ok(CheckResponse::NotModified);
128 };
129 let releases: Vec<GhRelease> = serde_json::from_str(&body)
130 .map_err(|e| CheckFailure::new(format!("could not parse GitHub release JSON: {e}")))?;
131 let asset_name = current_asset_name().map_err(CheckFailure::new)?;
132 let selected = select_release(&releases, &asset_name).ok_or_else(|| {
133 CheckFailure::new(format!(
134 "no stable GitHub release ships both {asset_name} and {CHECKSUMS_ASSET}"
135 ))
136 })?;
137 let latest = selected.version.to_string();
138 let newer = selected.version
139 > Version::parse(¤t)
140 .map_err(|e| CheckFailure::new(format!("invalid current version {current:?}: {e}")))?;
141
142 Ok(CheckResponse::Modified {
143 info: CheckResult {
144 current,
145 latest,
146 tag: selected.tag,
147 newer,
148 prerelease: false,
149 release_url: selected.release_url,
150 asset_name,
151 asset_url: selected.asset_url,
152 checksums_url: selected.checksums_url,
153 },
154 etag,
155 })
156}
157
158#[derive(Debug)]
159struct SelectedRelease {
160 version: Version,
161 tag: String,
162 release_url: String,
163 asset_url: String,
164 checksums_url: String,
165}
166
167fn select_release(releases: &[GhRelease], asset_name: &str) -> Option<SelectedRelease> {
168 releases
169 .iter()
170 .filter(|release| !release.draft && !release.prerelease)
171 .filter_map(|release| {
172 let version = parse_stable_tag(&release.tag_name)?;
173 let asset_url = release.asset_url(asset_name)?;
174 let checksums_url = release.asset_url(CHECKSUMS_ASSET)?;
175 Some(SelectedRelease {
176 version,
177 tag: release.tag_name.clone(),
178 release_url: if release.html_url.is_empty() {
179 format!("{GIT_URL}/releases/tag/{}", release.tag_name)
180 } else {
181 release.html_url.clone()
182 },
183 asset_url: asset_url.to_string(),
184 checksums_url: checksums_url.to_string(),
185 })
186 })
187 .max_by(|a, b| a.version.cmp(&b.version))
188}
189
190fn current_asset_name() -> Result<String, String> {
191 let arch = match std::env::consts::ARCH {
192 "x86_64" => "x86_64",
193 "aarch64" => "aarch64",
194 other => return Err(format!("unsupported architecture {other:?}")),
195 };
196 let platform = match std::env::consts::OS {
197 "macos" => "apple-darwin",
198 "linux" if cfg!(target_env = "gnu") => "unknown-linux-gnu",
199 "linux" => return Err("this build does not target GNU libc".into()),
200 other => return Err(format!("unsupported operating system {other:?}")),
201 };
202 Ok(format!("mach-{arch}-{platform}"))
203}
204
205pub fn install(info: &CheckResult) -> Result<InstallResult, String> {
211 install_with_progress(info, |_| {})
212}
213
214pub(crate) fn install_with_progress(
215 info: &CheckResult,
216 progress: impl FnMut(DownloadProgress),
217) -> Result<InstallResult, String> {
218 validate_install_info(info)?;
219 let destination = install_destination()?;
220 let manifest = http_get_text(
221 &info.checksums_url,
222 DOWNLOAD_TIMEOUT,
223 "application/octet-stream",
224 map_download_err,
225 )
226 .map_err(|e| format!("could not download checksums for {}: {e}", info.tag))?;
227 let expected_sha = checksum_for_asset(&manifest, &info.asset_name)?;
228 download_verified_binary(&info.asset_url, &expected_sha, &destination, progress)?;
229 Ok(InstallResult {
230 destination,
231 tag: info.tag.clone(),
232 })
233}
234
235fn validate_install_info(info: &CheckResult) -> Result<(), String> {
236 if info.current != current_version() {
237 return Err(format!(
238 "release check was produced for v{}, but this binary is v{}",
239 info.current,
240 current_version()
241 ));
242 }
243 if !info.newer {
244 return Err("refusing to install a release that is not newer than this binary".into());
245 }
246 let expected_asset = current_asset_name()?;
247 if info.asset_name != expected_asset {
248 return Err(format!(
249 "refusing asset {} on this platform (expected {expected_asset})",
250 info.asset_name
251 ));
252 }
253 let selected_version = parse_stable_tag(&info.tag)
254 .ok_or_else(|| format!("invalid stable release tag {:?}", info.tag))?;
255 let latest = Version::parse(&info.latest)
256 .map_err(|e| format!("invalid selected release version {:?}: {e}", info.latest))?;
257 if selected_version != latest || !latest.pre.is_empty() || info.latest != latest.to_string() {
258 return Err("selected release tag/version is inconsistent or not stable".into());
259 }
260 let current = Version::parse(current_version())
261 .map_err(|e| format!("invalid built-in version {:?}: {e}", current_version()))?;
262 if latest <= current {
263 return Err(format!(
264 "refusing to install v{latest} over v{current}: updates must move forward"
265 ));
266 }
267 let expected_asset_url = release_asset_url(&info.tag, &info.asset_name);
268 if info.asset_url != expected_asset_url {
269 return Err(format!(
270 "selected binary URL is not bound to {} and {}",
271 info.tag, info.asset_name
272 ));
273 }
274 let expected_checksums_url = release_asset_url(&info.tag, CHECKSUMS_ASSET);
275 if info.checksums_url != expected_checksums_url {
276 return Err(format!(
277 "selected checksum URL is not bound to {}",
278 info.tag
279 ));
280 }
281 Ok(())
282}
283
284fn release_asset_url(tag: &str, asset: &str) -> String {
285 format!("{RELEASE_DOWNLOAD_BASE}/{tag}/{asset}")
286}
287
288fn install_destination() -> Result<PathBuf, String> {
289 let explicit_install_dir = std::env::var_os("MACH_INSTALL_DIR")
290 .filter(|value| !value.is_empty())
291 .map(PathBuf::from);
292 let home = dirs::home_dir();
293 let current_exe = std::env::current_exe().ok();
294 let cargo_home = std::env::var_os("CARGO_HOME")
295 .filter(|value| !value.is_empty())
296 .map(PathBuf::from);
297 resolve_install_destination(
298 explicit_install_dir.as_deref(),
299 home.as_deref(),
300 current_exe.as_deref(),
301 cargo_home.as_deref(),
302 )
303}
304
305fn resolve_install_destination(
306 explicit_install_dir: Option<&Path>,
307 home: Option<&Path>,
308 current_exe: Option<&Path>,
309 cargo_home: Option<&Path>,
310) -> Result<PathBuf, String> {
311 if let Some(install_dir) = explicit_install_dir {
312 return Ok(install_dir.join("mach"));
313 }
314
315 let home = home.ok_or_else(|| "could not determine the install directory".to_string())?;
316 let cargo_bin = cargo_home
317 .map(Path::to_path_buf)
318 .unwrap_or_else(|| home.join(".cargo"))
319 .join("bin");
320 if current_exe.and_then(Path::parent) == Some(cargo_bin.as_path()) {
321 return Err("this mach executable is managed by Cargo; update it with \
322 'cargo install --locked mach-tui', or set MACH_INSTALL_DIR to install a release \
323 binary elsewhere"
324 .into());
325 }
326 Ok(home.join(".local/bin/mach"))
327}
328
329fn checksum_for_asset(manifest: &str, asset_name: &str) -> Result<String, String> {
330 let mut found = None;
331 for line in manifest.lines() {
332 let mut fields = line.split_whitespace();
333 let Some(digest) = fields.next() else {
334 continue;
335 };
336 let Some(name) = fields.next() else {
337 continue;
338 };
339 if name.trim_start_matches('*') != asset_name {
340 continue;
341 }
342 if fields.next().is_some() {
343 return Err(format!(
344 "{CHECKSUMS_ASSET} contains a malformed entry for {asset_name}"
345 ));
346 }
347 if found.is_some() {
348 return Err(format!(
349 "{CHECKSUMS_ASSET} contains duplicate entries for {asset_name}"
350 ));
351 }
352 if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
353 return Err(format!(
354 "{CHECKSUMS_ASSET} contains an invalid digest for {asset_name}"
355 ));
356 }
357 found = Some(digest.to_ascii_lowercase());
358 }
359 found.ok_or_else(|| format!("{CHECKSUMS_ASSET} has no entry for {asset_name}"))
360}
361
362fn download_verified_binary(
363 url: &str,
364 expected_sha: &str,
365 destination: &Path,
366 progress: impl FnMut(DownloadProgress),
367) -> Result<(), String> {
368 let config = ureq::Agent::config_builder()
369 .timeout_global(Some(DOWNLOAD_TIMEOUT))
370 .build();
371 let agent: ureq::Agent = config.into();
372 let mut response = agent
373 .get(url)
374 .header("User-Agent", USER_AGENT)
375 .header("Accept", "application/octet-stream")
376 .call()
377 .map_err(map_download_err)?;
378 let total = response.body().content_length();
379 if total.is_some_and(|total| total > MAX_BINARY_BYTES) {
380 return Err(format!(
381 "release binary exceeds the {} MiB safety limit",
382 MAX_BINARY_BYTES / 1024 / 1024
383 ));
384 }
385 write_verified_binary(
386 response.body_mut().as_reader(),
387 expected_sha,
388 destination,
389 total,
390 progress,
391 )
392}
393
394fn write_verified_binary<R: Read>(
395 mut source: R,
396 expected_sha: &str,
397 destination: &Path,
398 expected_total: Option<u64>,
399 mut progress: impl FnMut(DownloadProgress),
400) -> Result<(), String> {
401 #[cfg(not(unix))]
402 return Err("self-update is supported only on Unix platforms".into());
403
404 #[cfg(unix)]
405 {
406 let parent = destination
407 .parent()
408 .filter(|path| !path.as_os_str().is_empty())
409 .ok_or_else(|| "install destination has no parent directory".to_string())?;
410 fs::create_dir_all(parent).map_err(|e| {
411 format!(
412 "could not create install directory {}: {e}",
413 parent.display()
414 )
415 })?;
416 let parent_dir = File::open(parent)
417 .map_err(|e| format!("could not open install directory {}: {e}", parent.display()))?;
418 let temp_path = parent.join(format!(".mach.{}.tmp", uuid::Uuid::new_v4()));
419 let mut temp_file = OpenOptions::new()
420 .write(true)
421 .create_new(true)
422 .open(&temp_path)
423 .map_err(|e| format!("could not create temporary binary: {e}"))?;
424
425 progress(DownloadProgress {
426 downloaded: 0,
427 total: expected_total,
428 });
429
430 let write_result = (|| -> Result<(), String> {
431 let mut hasher = Sha256::new();
432 let mut downloaded = 0_u64;
433 let mut buffer = [0_u8; 64 * 1024];
434 loop {
435 let read = source
436 .read(&mut buffer)
437 .map_err(|e| format!("could not read release binary: {e}"))?;
438 if read == 0 {
439 break;
440 }
441 downloaded = downloaded
442 .checked_add(read as u64)
443 .ok_or_else(|| "release binary is too large".to_string())?;
444 if downloaded > MAX_BINARY_BYTES {
445 return Err(format!(
446 "release binary exceeds the {} MiB safety limit",
447 MAX_BINARY_BYTES / 1024 / 1024
448 ));
449 }
450 hasher.update(&buffer[..read]);
451 temp_file
452 .write_all(&buffer[..read])
453 .map_err(|e| format!("could not write temporary binary: {e}"))?;
454 progress(DownloadProgress {
455 downloaded,
456 total: expected_total,
457 });
458 }
459
460 let actual_sha = format!("{:x}", hasher.finalize());
461 if actual_sha != expected_sha {
462 return Err(format!(
463 "SHA-256 verification failed (expected {expected_sha}, got {actual_sha})"
464 ));
465 }
466 temp_file
467 .set_permissions(fs::Permissions::from_mode(0o755))
468 .map_err(|e| format!("could not mark temporary binary executable: {e}"))?;
469 temp_file
470 .sync_all()
471 .map_err(|e| format!("could not sync temporary binary: {e}"))?;
472 Ok(())
473 })();
474 drop(temp_file);
475
476 if let Err(error) = write_result {
477 let _ = fs::remove_file(&temp_path);
478 return Err(error);
479 }
480 if let Err(error) = fs::rename(&temp_path, destination) {
481 let _ = fs::remove_file(&temp_path);
482 return Err(format!(
483 "could not replace {} atomically: {error}",
484 destination.display()
485 ));
486 }
487 parent_dir
488 .sync_all()
489 .map_err(|e| format!("could not sync install directory {}: {e}", parent.display()))?;
490 Ok(())
491 }
492}
493
494#[cfg(test)]
495fn sha256_hex(bytes: &[u8]) -> String {
496 format!("{:x}", Sha256::digest(bytes))
497}
498
499#[derive(Debug, Deserialize)]
500struct GhRelease {
501 tag_name: String,
502 #[serde(default)]
503 html_url: String,
504 #[serde(default)]
505 prerelease: bool,
506 #[serde(default)]
507 draft: bool,
508 #[serde(default)]
509 assets: Vec<GhAsset>,
510}
511
512impl GhRelease {
513 fn asset_url(&self, name: &str) -> Option<&str> {
514 self.assets
515 .iter()
516 .find(|asset| asset.name == name && !asset.browser_download_url.is_empty())
517 .map(|asset| asset.browser_download_url.as_str())
518 }
519}
520
521#[derive(Debug, Deserialize)]
522struct GhAsset {
523 name: String,
524 #[serde(default)]
525 browser_download_url: String,
526}
527
528#[derive(Debug)]
529enum ReleaseDocument {
530 Modified { body: String, etag: Option<String> },
531 NotModified,
532}
533
534fn fetch_releases(url: &str, etag: Option<&str>) -> Result<ReleaseDocument, CheckFailure> {
535 let config = ureq::Agent::config_builder()
536 .timeout_global(Some(TIMEOUT))
537 .http_status_as_error(false)
538 .build();
539 let agent: ureq::Agent = config.into();
540 let mut request = agent
541 .get(url)
542 .header("User-Agent", USER_AGENT)
543 .header("Accept", "application/vnd.github+json");
544 if let Some(etag) = etag {
545 request = request.header("If-None-Match", etag);
546 }
547 let mut response = request
548 .call()
549 .map_err(|error| CheckFailure::new(map_ureq_err(error)))?;
550 let status = response.status().as_u16();
551 if status == 304 {
552 return Ok(ReleaseDocument::NotModified);
553 }
554 if status != 200 {
555 let now = Utc::now().timestamp();
556 let retry_at = response
557 .headers()
558 .get("Retry-After")
559 .and_then(|value| value.to_str().ok())
560 .and_then(|value| parse_retry_after(value, now))
561 .or_else(|| {
562 let remaining = response
563 .headers()
564 .get("X-RateLimit-Remaining")
565 .and_then(|value| value.to_str().ok());
566 (remaining == Some("0"))
567 .then(|| {
568 response
569 .headers()
570 .get("X-RateLimit-Reset")
571 .and_then(|value| value.to_str().ok())
572 .and_then(parse_nonnegative_decimal)
573 })
574 .flatten()
575 });
576 let message = if status == 404 {
577 "no GitHub releases yet — publish one, or install from git".into()
578 } else {
579 format!("GitHub API HTTP {status}")
580 };
581 return Err(CheckFailure { message, retry_at });
582 }
583 let response_etag = response
584 .headers()
585 .get("ETag")
586 .and_then(|value| value.to_str().ok())
587 .map(str::to_owned);
588 let body = read_bounded_text(response.body_mut().as_reader(), MAX_TEXT_BYTES)
589 .map_err(CheckFailure::new)?;
590 Ok(ReleaseDocument::Modified {
591 body,
592 etag: response_etag,
593 })
594}
595
596fn parse_retry_after(value: &str, now: i64) -> Option<i64> {
597 let value = value.trim();
598 if let Some(seconds) = parse_nonnegative_decimal(value) {
599 return Some(now.saturating_add(seconds));
600 }
601 let timestamp = httpdate::parse_http_date(value).ok()?;
602 let seconds = timestamp.duration_since(UNIX_EPOCH).ok()?.as_secs();
603 Some(i64::try_from(seconds).unwrap_or(i64::MAX))
604}
605
606fn parse_nonnegative_decimal(value: &str) -> Option<i64> {
607 let value = value.trim();
608 if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
609 return None;
610 }
611 Some(value.bytes().fold(0_i64, |number, byte| {
612 number
613 .saturating_mul(10)
614 .saturating_add(i64::from(byte - b'0'))
615 }))
616}
617
618fn http_get_text(
619 url: &str,
620 timeout: Duration,
621 accept: &str,
622 map_error: fn(ureq::Error) -> String,
623) -> Result<String, String> {
624 let config = ureq::Agent::config_builder()
625 .timeout_global(Some(timeout))
626 .build();
627 let agent: ureq::Agent = config.into();
628 let mut response = agent
629 .get(url)
630 .header("User-Agent", USER_AGENT)
631 .header("Accept", accept)
632 .call()
633 .map_err(map_error)?;
634 read_bounded_text(response.body_mut().as_reader(), MAX_TEXT_BYTES)
635}
636
637fn read_bounded_text<R: Read>(source: R, max_bytes: u64) -> Result<String, String> {
638 let mut bytes = Vec::new();
639 source
640 .take(max_bytes.saturating_add(1))
641 .read_to_end(&mut bytes)
642 .map_err(|e| format!("could not read response: {e}"))?;
643 if bytes.len() as u64 > max_bytes {
644 return Err(format!("response exceeds the {max_bytes}-byte limit"));
645 }
646 String::from_utf8(bytes).map_err(|e| format!("response is not valid UTF-8: {e}"))
647}
648
649fn map_download_err(error: ureq::Error) -> String {
650 match error {
651 ureq::Error::StatusCode(code) => format!("download HTTP {code}"),
652 other => format!("download failed: {other}"),
653 }
654}
655
656fn parse_stable_tag(tag: &str) -> Option<Version> {
657 if tag != tag.trim() {
658 return None;
659 }
660 let tag = tag.trim();
661 let normalized = tag.strip_prefix('v').unwrap_or(tag);
662 let version = Version::parse(normalized).ok()?;
663 if !version.pre.is_empty() || !version.build.is_empty() || normalized != version.to_string() {
664 return None;
665 }
666 Some(version)
667}
668
669fn map_ureq_err(e: ureq::Error) -> String {
670 match e {
671 ureq::Error::StatusCode(404) => {
672 "no GitHub releases yet — publish one, or install from git".into()
673 }
674 ureq::Error::StatusCode(code) => format!("GitHub API HTTP {code}"),
675 other => format!("network error: {other}"),
676 }
677}
678
679pub fn normalize_tag(tag: &str) -> String {
681 let tag = tag.trim();
682 tag.strip_prefix('v').unwrap_or(tag).to_string()
683}
684
685pub fn is_newer(latest: &str, current: &str) -> Option<bool> {
687 let a = Version::parse(&normalize_tag(latest)).ok()?;
688 let b = Version::parse(&normalize_tag(current)).ok()?;
689 Some(a > b)
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695 use std::net::TcpListener;
696 use std::sync::mpsc;
697
698 fn serve_once(response: impl Into<String>) -> (String, mpsc::Receiver<String>) {
699 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
700 let address = listener.local_addr().unwrap();
701 let (request_tx, request_rx) = mpsc::channel();
702 let response = response.into();
703 std::thread::spawn(move || {
704 let (mut stream, _) = listener.accept().unwrap();
705 let mut request = Vec::new();
706 let mut buffer = [0_u8; 1024];
707 while !request.windows(4).any(|window| window == b"\r\n\r\n") {
708 let read = stream.read(&mut buffer).unwrap();
709 if read == 0 {
710 break;
711 }
712 request.extend_from_slice(&buffer[..read]);
713 }
714 let _ = request_tx.send(String::from_utf8(request).unwrap());
715 stream.write_all(response.as_bytes()).unwrap();
716 });
717 (format!("http://{address}/releases"), request_rx)
718 }
719
720 fn release(tag: &str, prerelease: bool, assets: &[(&str, &str)]) -> GhRelease {
721 GhRelease {
722 tag_name: tag.into(),
723 html_url: format!("https://github.test/releases/tag/{tag}"),
724 prerelease,
725 draft: false,
726 assets: assets
727 .iter()
728 .map(|(name, url)| GhAsset {
729 name: (*name).into(),
730 browser_download_url: (*url).into(),
731 })
732 .collect(),
733 }
734 }
735
736 fn valid_install_result() -> CheckResult {
737 let current = Version::parse(current_version()).unwrap();
738 let latest = Version::new(
739 current.major,
740 current.minor,
741 current.patch.checked_add(1).unwrap(),
742 );
743 let tag = format!("v{latest}");
744 let asset_name = current_asset_name().unwrap();
745 CheckResult {
746 current: current.to_string(),
747 latest: latest.to_string(),
748 tag: tag.clone(),
749 newer: true,
750 prerelease: false,
751 release_url: format!("https://github.test/releases/tag/{tag}"),
752 asset_url: release_asset_url(&tag, &asset_name),
753 checksums_url: release_asset_url(&tag, CHECKSUMS_ASSET),
754 asset_name,
755 }
756 }
757
758 #[test]
759 fn normalizes_v_prefix() {
760 assert_eq!(normalize_tag("v1.2.3"), "1.2.3");
761 assert_eq!(normalize_tag(" 1.0.0 "), "1.0.0");
762 }
763
764 #[test]
765 fn compares_semver() {
766 assert_eq!(is_newer("0.2.0", "0.1.0"), Some(true));
767 assert_eq!(is_newer("0.1.0", "0.1.0"), Some(false));
768 assert_eq!(is_newer("0.1.0", "0.2.0"), Some(false));
769 assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true));
770 assert_eq!(is_newer("0.1.1-rc.1", "0.1.0"), Some(true));
771 }
772
773 #[test]
774 fn conditional_release_request_reuses_etag_and_accepts_not_modified() {
775 let (url, request) = serve_once(
776 "HTTP/1.1 304 Not Modified\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
777 );
778
779 assert!(matches!(
780 fetch_releases(&url, Some("\"release-etag\"")).unwrap(),
781 ReleaseDocument::NotModified
782 ));
783 assert!(
784 request
785 .recv()
786 .unwrap()
787 .to_ascii_lowercase()
788 .contains("if-none-match: \"release-etag\"")
789 );
790 }
791
792 #[test]
793 fn modified_release_response_captures_the_new_etag() {
794 let (url, _) = serve_once(
795 "HTTP/1.1 200 OK\r\nETag: \"next-etag\"\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]",
796 );
797
798 let ReleaseDocument::Modified { body, etag } = fetch_releases(&url, None).unwrap() else {
799 panic!("a 200 response must carry a release document");
800 };
801 assert_eq!(body, "[]");
802 assert_eq!(etag.as_deref(), Some("\"next-etag\""));
803 }
804
805 #[test]
806 fn rate_limited_release_request_preserves_retry_after() {
807 let (url, _) = serve_once(
808 "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 120\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
809 );
810 let before = Utc::now().timestamp();
811
812 let error = fetch_releases(&url, None).expect_err("rate limit should fail the check");
813
814 assert_eq!(error.message, "GitHub API HTTP 429");
815 assert!(error.retry_at.is_some_and(|retry_at| {
816 retry_at >= before + 120 && retry_at <= Utc::now().timestamp() + 120
817 }));
818 }
819
820 #[test]
821 fn retry_after_accepts_every_http_date_form() {
822 let expected = 784_111_777;
823 for value in [
824 "Sun, 06 Nov 1994 08:49:37 GMT",
825 "Sunday, 06-Nov-94 08:49:37 GMT",
826 "Sun Nov 6 08:49:37 1994",
827 ] {
828 let (url, _) = serve_once(format!(
829 "HTTP/1.1 429 Too Many Requests\r\nRetry-After: {value}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
830 ));
831
832 let error = fetch_releases(&url, None).expect_err("rate limit should fail the check");
833
834 assert_eq!(error.retry_at, Some(expected), "failed to parse {value}");
835 }
836 }
837
838 #[test]
839 fn rate_limit_reset_is_used_only_when_the_budget_is_exhausted() {
840 let reset = Utc::now().timestamp() + 3_600;
841 let (url, _) = serve_once(format!(
842 "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"
843 ));
844 let error = fetch_releases(&url, None).expect_err("server error should fail the check");
845 assert_eq!(error.retry_at, None);
846
847 let (url, _) = serve_once(format!(
848 "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"
849 ));
850 let error = fetch_releases(&url, None).expect_err("rate limit should fail the check");
851 assert_eq!(error.retry_at, Some(reset));
852 }
853
854 #[test]
855 fn stable_release_tags_must_be_canonical_and_not_prereleases() {
856 assert_eq!(parse_stable_tag("v1.2.3").unwrap().to_string(), "1.2.3");
857 assert!(parse_stable_tag("1.2.3+build.4").is_none());
858 assert!(parse_stable_tag("v01.2.3").is_none());
859 assert!(parse_stable_tag("v1.2.3-rc.1").is_none());
860 assert!(parse_stable_tag(" v1.2.3").is_none());
861 }
862
863 #[test]
864 fn install_hint_prefers_verified_self_update() {
865 let r = CheckResult {
866 current: "0.1.0".into(),
867 latest: "0.1.0".into(),
868 tag: "v0.1.0".into(),
869 newer: false,
870 prerelease: false,
871 release_url: String::new(),
872 asset_name: "mach-aarch64-apple-darwin".into(),
873 asset_url: "https://example.test/mach".into(),
874 checksums_url: "https://example.test/SHA256SUMS".into(),
875 };
876 let h = r.install_hint();
877 assert!(h.contains("mach update --install"));
878 assert!(h.contains("cargo install --locked mach-tui"));
879 assert!(!h.contains("curl"));
880 }
881
882 #[test]
883 fn cargo_managed_binary_requires_cargo_or_an_explicit_release_destination() {
884 let home = Path::new("/home/alice");
885 let cargo_home = home.join(".cargo");
886 let current_exe = cargo_home.join("bin/mach");
887
888 let error = resolve_install_destination(None, Some(home), Some(¤t_exe), None)
889 .expect_err("a Cargo-managed executable must not create a shadow release install");
890 assert!(error.contains("Cargo"));
891 assert!(error.contains("cargo install --locked mach-tui"));
892
893 assert_eq!(
894 resolve_install_destination(
895 Some(Path::new("/opt/mach/bin")),
896 Some(home),
897 Some(¤t_exe),
898 None,
899 )
900 .unwrap(),
901 PathBuf::from("/opt/mach/bin/mach"),
902 "an explicit destination is an intentional ownership change"
903 );
904
905 let custom_cargo_home = Path::new("/srv/cargo");
906 let custom_exe = custom_cargo_home.join("bin/mach");
907 assert!(
908 resolve_install_destination(
909 None,
910 Some(home),
911 Some(&custom_exe),
912 Some(custom_cargo_home),
913 )
914 .is_err(),
915 "CARGO_HOME must participate in ownership detection"
916 );
917 }
918
919 #[test]
920 fn selector_ignores_legacy_prereleases_and_binds_required_assets() {
921 let releases = vec![
922 release("v1.21.9", false, &[]),
923 release(
924 "v2.0.0-rc.1",
925 false,
926 &[
927 ("mach-x86_64-unknown-linux-gnu", "https://bad/tagged-rc"),
928 (CHECKSUMS_ASSET, "https://bad/tagged-rc-sums"),
929 ],
930 ),
931 release(
932 "v0.2.0-rc.1",
933 true,
934 &[
935 ("mach-x86_64-unknown-linux-gnu", "https://bad/rc"),
936 (CHECKSUMS_ASSET, "https://bad/rc-sums"),
937 ],
938 ),
939 release(
940 "v0.1.2",
941 false,
942 &[
943 ("mach-x86_64-unknown-linux-gnu", "https://good/mach"),
944 (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
945 ],
946 ),
947 ];
948
949 let selected = select_release(&releases, "mach-x86_64-unknown-linux-gnu")
950 .expect("stable release with both assets");
951
952 assert_eq!(selected.version.to_string(), "0.1.2");
953 assert_eq!(selected.tag, "v0.1.2");
954 assert_eq!(selected.asset_url, "https://good/mach");
955 assert_eq!(selected.checksums_url, "https://good/SHA256SUMS");
956 }
957
958 #[test]
959 fn selector_allows_a_legitimate_major_upgrade() {
960 let releases = vec![release(
961 "v1.0.0",
962 false,
963 &[
964 ("mach-aarch64-apple-darwin", "https://good/mach"),
965 (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
966 ],
967 )];
968
969 let selected =
970 select_release(&releases, "mach-aarch64-apple-darwin").expect("major upgrade");
971 assert_eq!(selected.version.to_string(), "1.0.0");
972 }
973
974 #[test]
975 fn selector_still_returns_the_latest_release_when_this_build_is_ahead() {
976 let releases = vec![release(
977 "v0.9.0",
978 false,
979 &[
980 ("mach-aarch64-apple-darwin", "https://good/mach"),
981 (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
982 ],
983 )];
984
985 let selected = select_release(&releases, "mach-aarch64-apple-darwin")
986 .expect("an older eligible release is still the latest published release");
987 assert_eq!(selected.version.to_string(), "0.9.0");
988 assert_eq!(
989 is_newer(&selected.version.to_string(), "1.0.0"),
990 Some(false)
991 );
992 }
993
994 #[test]
995 fn selector_rejects_releases_missing_the_binary_or_checksum_manifest() {
996 let releases = vec![
997 release(
998 "v0.3.0",
999 false,
1000 &[(CHECKSUMS_ASSET, "https://bad/only-sums")],
1001 ),
1002 release(
1003 "v0.2.0",
1004 false,
1005 &[("mach-x86_64-unknown-linux-gnu", "https://bad/only-bin")],
1006 ),
1007 ];
1008
1009 assert!(select_release(&releases, "mach-x86_64-unknown-linux-gnu").is_none());
1010 }
1011
1012 #[test]
1013 fn checksum_parser_requires_one_exact_valid_asset_entry() {
1014 let digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1015 assert_eq!(
1016 checksum_for_asset(
1017 &format!("{digest} mach-aarch64-apple-darwin\n"),
1018 "mach-aarch64-apple-darwin",
1019 )
1020 .unwrap(),
1021 digest,
1022 );
1023 assert!(checksum_for_asset(&format!("{digest} mach-other\n"), "mach").is_err());
1024 assert!(checksum_for_asset(&format!("{digest} mach\n{digest} mach\n"), "mach",).is_err());
1025 assert!(checksum_for_asset(&format!("{digest} mach extra\n"), "mach").is_err());
1026 }
1027
1028 #[test]
1029 fn installer_rejects_urls_not_bound_to_the_selected_tag_and_asset() {
1030 let mut result = valid_install_result();
1031 validate_install_info(&result).unwrap();
1032
1033 result.asset_url.push_str("?wrong-release");
1034 assert!(validate_install_info(&result).is_err());
1035 }
1036
1037 #[test]
1038 fn installer_rejects_stale_or_non_update_check_results() {
1039 let mut stale = valid_install_result();
1040 stale.current = "0.0.0".into();
1041 assert!(
1042 validate_install_info(&stale)
1043 .unwrap_err()
1044 .contains("produced for")
1045 );
1046
1047 let mut not_newer = valid_install_result();
1048 not_newer.newer = false;
1049 assert!(
1050 validate_install_info(¬_newer)
1051 .unwrap_err()
1052 .contains("not newer")
1053 );
1054 }
1055
1056 #[test]
1057 fn installer_rejects_reinstalls_and_downgrades() {
1058 let mut reinstall = valid_install_result();
1059 reinstall.latest = current_version().into();
1060 reinstall.tag = format!("v{}", current_version());
1061 reinstall.asset_url = release_asset_url(&reinstall.tag, &reinstall.asset_name);
1062 reinstall.checksums_url = release_asset_url(&reinstall.tag, CHECKSUMS_ASSET);
1063 assert!(
1064 validate_install_info(&reinstall)
1065 .unwrap_err()
1066 .contains("must move forward")
1067 );
1068
1069 let current = Version::parse(current_version()).unwrap();
1070 let lower = Version::new(0, 0, 0);
1071 assert!(lower < current, "test package version must be above 0.0.0");
1072 let mut downgrade = valid_install_result();
1073 downgrade.latest = lower.to_string();
1074 downgrade.tag = format!("v{lower}");
1075 downgrade.asset_url = release_asset_url(&downgrade.tag, &downgrade.asset_name);
1076 downgrade.checksums_url = release_asset_url(&downgrade.tag, CHECKSUMS_ASSET);
1077 assert!(
1078 validate_install_info(&downgrade)
1079 .unwrap_err()
1080 .contains("must move forward")
1081 );
1082 }
1083
1084 #[test]
1085 fn text_responses_are_bounded() {
1086 assert_eq!(
1087 read_bounded_text(std::io::Cursor::new(b"four"), 4).unwrap(),
1088 "four"
1089 );
1090 assert!(
1091 read_bounded_text(std::io::Cursor::new(b"oversized"), 4)
1092 .unwrap_err()
1093 .contains("4-byte limit")
1094 );
1095 }
1096
1097 #[test]
1098 fn verified_replace_preserves_the_existing_binary_on_hash_failure() {
1099 let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1100 fs::create_dir(&dir).unwrap();
1101 let destination = dir.join("mach");
1102 fs::write(&destination, b"old binary").unwrap();
1103
1104 let error = write_verified_binary(
1105 std::io::Cursor::new(b"corrupt download"),
1106 &"0".repeat(64),
1107 &destination,
1108 None,
1109 |_| {},
1110 )
1111 .unwrap_err();
1112
1113 assert!(error.contains("SHA-256"));
1114 assert_eq!(fs::read(&destination).unwrap(), b"old binary");
1115 fs::remove_dir_all(dir).unwrap();
1116 }
1117
1118 #[test]
1119 fn verified_replace_installs_an_executable_binary() {
1120 let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1121 fs::create_dir(&dir).unwrap();
1122 let destination = dir.join("mach");
1123 let binary = b"verified binary";
1124 let digest = sha256_hex(binary);
1125
1126 write_verified_binary(
1127 std::io::Cursor::new(binary),
1128 &digest,
1129 &destination,
1130 None,
1131 |_| {},
1132 )
1133 .unwrap();
1134
1135 assert_eq!(fs::read(&destination).unwrap(), binary);
1136 #[cfg(unix)]
1137 {
1138 use std::os::unix::fs::PermissionsExt;
1139 assert_eq!(
1140 fs::metadata(&destination).unwrap().permissions().mode() & 0o777,
1141 0o755
1142 );
1143 }
1144 fs::remove_dir_all(dir).unwrap();
1145 }
1146
1147 #[test]
1148 fn verified_replace_reports_monotonic_download_progress() {
1149 let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1150 fs::create_dir(&dir).unwrap();
1151 let destination = dir.join("mach");
1152 let binary = vec![b'x'; 150_000];
1153 let digest = sha256_hex(&binary);
1154 let mut progress = Vec::new();
1155
1156 write_verified_binary(
1157 std::io::Cursor::new(&binary),
1158 &digest,
1159 &destination,
1160 Some(binary.len() as u64),
1161 |event| progress.push(event),
1162 )
1163 .unwrap();
1164
1165 assert_eq!(
1166 progress.first(),
1167 Some(&DownloadProgress {
1168 downloaded: 0,
1169 total: Some(binary.len() as u64),
1170 })
1171 );
1172 assert_eq!(
1173 progress.last(),
1174 Some(&DownloadProgress {
1175 downloaded: binary.len() as u64,
1176 total: Some(binary.len() as u64),
1177 })
1178 );
1179 assert!(
1180 progress
1181 .windows(2)
1182 .all(|pair| pair[0].downloaded <= pair[1].downloaded)
1183 );
1184 fs::remove_dir_all(dir).unwrap();
1185 }
1186}