Skip to main content

mach/
update.rs

1//! Check for and install newer builds.
2//!
3//! Source of truth: GitHub Releases on `Q1CHENL/mach`. Fresh installs use the
4//! release installer; self-updates download and verify the exact release asset
5//! directly. The TUI schedules its next background check one day after success;
6//! failures retry after an hour by default and honor server backoff. Install
7//! remains an explicit action through `/update` or `mach update --install`.
8
9use 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
22/// Repo used for release checks and install.
23pub 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    /// Exact Git tag selected from the GitHub release response.
39    pub tag: String,
40    pub newer: bool,
41    pub prerelease: bool,
42    pub release_url: String,
43    /// Exact platform binary and URLs bound to [`tag`](Self::tag).
44    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    /// One-line status for the TUI / CLI.
87    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    /// How to install this build.
99    pub fn install_hint(&self) -> String {
100        "mach update --install\n# or, for Cargo installs: cargo install --locked mach-tui".into()
101    }
102}
103
104/// Built-in version of this binary.
105pub fn current_version() -> &'static str {
106    crate::VERSION
107}
108
109/// Ask GitHub what the latest release is and compare to this binary.
110///
111/// Picks the highest stable semver release that ships both this platform's
112/// binary and its checksum manifest. Requiring those assets excludes the
113/// disconnected legacy Python releases without blocking legitimate majors.
114pub 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
123/// Conditional release check used by the long-running TUI scheduler.
124pub(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(&current)
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
205/// Install the exact release and platform asset returned by [`check`].
206///
207/// The binary is downloaded and verified in-process. No downloaded script is
208/// executed. The replacement is written, synced, chmodded, and atomically
209/// renamed within the destination directory before that directory is synced.
210pub 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
679/// Strip one conventional leading `v` and whitespace.
680pub fn normalize_tag(tag: &str) -> String {
681    let tag = tag.trim();
682    tag.strip_prefix('v').unwrap_or(tag).to_string()
683}
684
685/// True when `latest` is a higher semantic version than `current`.
686pub 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(&current_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(&current_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(&not_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}