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 checks in the background at most once per day; install
6//! remains an explicit action through `/update` or `mach update --install`.
7
8use std::fs::{self, File, OpenOptions};
9use std::io::{Read, Write};
10use std::path::{Path, PathBuf};
11use std::time::Duration;
12
13use semver::Version;
14use serde::Deserialize;
15use sha2::{Digest, Sha256};
16
17#[cfg(unix)]
18use std::os::unix::fs::PermissionsExt;
19
20/// Repo used for release checks and install.
21pub const REPO: &str = "Q1CHENL/mach";
22pub const GIT_URL: &str = "https://github.com/Q1CHENL/mach";
23const RELEASES_URL: &str = "https://api.github.com/repos/Q1CHENL/mach/releases?per_page=100";
24const RELEASE_DOWNLOAD_BASE: &str = "https://github.com/Q1CHENL/mach/releases/download";
25const CHECKSUMS_ASSET: &str = "SHA256SUMS";
26const USER_AGENT: &str = concat!("mach/", env!("CARGO_PKG_VERSION"));
27const TIMEOUT: Duration = Duration::from_secs(8);
28const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120);
29const MAX_TEXT_BYTES: u64 = 1024 * 1024;
30const MAX_BINARY_BYTES: u64 = 128 * 1024 * 1024;
31
32#[derive(Debug, Clone)]
33pub struct CheckResult {
34    pub current: String,
35    pub latest: String,
36    /// Exact Git tag selected from the GitHub release response.
37    pub tag: String,
38    pub newer: bool,
39    pub prerelease: bool,
40    pub release_url: String,
41    /// Exact platform binary and URLs bound to [`tag`](Self::tag).
42    pub asset_name: String,
43    pub asset_url: String,
44    pub checksums_url: String,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct InstallResult {
49    pub destination: PathBuf,
50    pub tag: String,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub(crate) struct DownloadProgress {
55    pub(crate) downloaded: u64,
56    pub(crate) total: Option<u64>,
57}
58
59impl CheckResult {
60    /// One-line status for the TUI / CLI.
61    pub fn summary(&self) -> String {
62        if self.newer {
63            format!(
64                "Update available: v{} → v{}  ({})",
65                self.current, self.latest, self.release_url
66            )
67        } else {
68            format!("Up to date (v{})", self.current)
69        }
70    }
71
72    /// How to install this build.
73    pub fn install_hint(&self) -> String {
74        "mach update --install\n# or, for Cargo installs: cargo install --locked mach-tui".into()
75    }
76}
77
78/// Built-in version of this binary.
79pub fn current_version() -> &'static str {
80    crate::VERSION
81}
82
83/// Ask GitHub what the latest release is and compare to this binary.
84///
85/// Picks the highest stable semver release that ships both this platform's
86/// binary and its checksum manifest. Requiring those assets excludes the
87/// disconnected legacy Python releases without blocking legitimate majors.
88pub fn check() -> Result<CheckResult, String> {
89    let current = current_version().to_string();
90    let body = http_get(RELEASES_URL)?;
91    let releases: Vec<GhRelease> = serde_json::from_str(&body)
92        .map_err(|e| format!("could not parse GitHub release JSON: {e}"))?;
93    let asset_name = current_asset_name()?;
94    let selected = select_release(&releases, &asset_name).ok_or_else(|| {
95        format!("no stable GitHub release ships both {asset_name} and {CHECKSUMS_ASSET}")
96    })?;
97    let latest = selected.version.to_string();
98    let newer = selected.version
99        > Version::parse(&current)
100            .map_err(|e| format!("invalid current version {current:?}: {e}"))?;
101
102    Ok(CheckResult {
103        current,
104        latest,
105        tag: selected.tag,
106        newer,
107        prerelease: false,
108        release_url: selected.release_url,
109        asset_name,
110        asset_url: selected.asset_url,
111        checksums_url: selected.checksums_url,
112    })
113}
114
115#[derive(Debug)]
116struct SelectedRelease {
117    version: Version,
118    tag: String,
119    release_url: String,
120    asset_url: String,
121    checksums_url: String,
122}
123
124fn select_release(releases: &[GhRelease], asset_name: &str) -> Option<SelectedRelease> {
125    releases
126        .iter()
127        .filter(|release| !release.draft && !release.prerelease)
128        .filter_map(|release| {
129            let version = parse_stable_tag(&release.tag_name)?;
130            let asset_url = release.asset_url(asset_name)?;
131            let checksums_url = release.asset_url(CHECKSUMS_ASSET)?;
132            Some(SelectedRelease {
133                version,
134                tag: release.tag_name.clone(),
135                release_url: if release.html_url.is_empty() {
136                    format!("{GIT_URL}/releases/tag/{}", release.tag_name)
137                } else {
138                    release.html_url.clone()
139                },
140                asset_url: asset_url.to_string(),
141                checksums_url: checksums_url.to_string(),
142            })
143        })
144        .max_by(|a, b| a.version.cmp(&b.version))
145}
146
147fn current_asset_name() -> Result<String, String> {
148    let arch = match std::env::consts::ARCH {
149        "x86_64" => "x86_64",
150        "aarch64" => "aarch64",
151        other => return Err(format!("unsupported architecture {other:?}")),
152    };
153    let platform = match std::env::consts::OS {
154        "macos" => "apple-darwin",
155        "linux" if cfg!(target_env = "gnu") => "unknown-linux-gnu",
156        "linux" => return Err("this build does not target GNU libc".into()),
157        other => return Err(format!("unsupported operating system {other:?}")),
158    };
159    Ok(format!("mach-{arch}-{platform}"))
160}
161
162/// Install the exact release and platform asset returned by [`check`].
163///
164/// The binary is downloaded and verified in-process. No downloaded script is
165/// executed. The replacement is written, synced, chmodded, and atomically
166/// renamed within the destination directory before that directory is synced.
167pub fn install(info: &CheckResult) -> Result<InstallResult, String> {
168    install_with_progress(info, |_| {})
169}
170
171pub(crate) fn install_with_progress(
172    info: &CheckResult,
173    progress: impl FnMut(DownloadProgress),
174) -> Result<InstallResult, String> {
175    validate_install_info(info)?;
176    let destination = install_destination()?;
177    let manifest = http_get_text(
178        &info.checksums_url,
179        DOWNLOAD_TIMEOUT,
180        "application/octet-stream",
181        map_download_err,
182    )
183    .map_err(|e| format!("could not download checksums for {}: {e}", info.tag))?;
184    let expected_sha = checksum_for_asset(&manifest, &info.asset_name)?;
185    download_verified_binary(&info.asset_url, &expected_sha, &destination, progress)?;
186    Ok(InstallResult {
187        destination,
188        tag: info.tag.clone(),
189    })
190}
191
192fn validate_install_info(info: &CheckResult) -> Result<(), String> {
193    if info.current != current_version() {
194        return Err(format!(
195            "release check was produced for v{}, but this binary is v{}",
196            info.current,
197            current_version()
198        ));
199    }
200    if !info.newer {
201        return Err("refusing to install a release that is not newer than this binary".into());
202    }
203    let expected_asset = current_asset_name()?;
204    if info.asset_name != expected_asset {
205        return Err(format!(
206            "refusing asset {} on this platform (expected {expected_asset})",
207            info.asset_name
208        ));
209    }
210    let selected_version = parse_stable_tag(&info.tag)
211        .ok_or_else(|| format!("invalid stable release tag {:?}", info.tag))?;
212    let latest = Version::parse(&info.latest)
213        .map_err(|e| format!("invalid selected release version {:?}: {e}", info.latest))?;
214    if selected_version != latest || !latest.pre.is_empty() || info.latest != latest.to_string() {
215        return Err("selected release tag/version is inconsistent or not stable".into());
216    }
217    let current = Version::parse(current_version())
218        .map_err(|e| format!("invalid built-in version {:?}: {e}", current_version()))?;
219    if latest <= current {
220        return Err(format!(
221            "refusing to install v{latest} over v{current}: updates must move forward"
222        ));
223    }
224    let expected_asset_url = release_asset_url(&info.tag, &info.asset_name);
225    if info.asset_url != expected_asset_url {
226        return Err(format!(
227            "selected binary URL is not bound to {} and {}",
228            info.tag, info.asset_name
229        ));
230    }
231    let expected_checksums_url = release_asset_url(&info.tag, CHECKSUMS_ASSET);
232    if info.checksums_url != expected_checksums_url {
233        return Err(format!(
234            "selected checksum URL is not bound to {}",
235            info.tag
236        ));
237    }
238    Ok(())
239}
240
241fn release_asset_url(tag: &str, asset: &str) -> String {
242    format!("{RELEASE_DOWNLOAD_BASE}/{tag}/{asset}")
243}
244
245fn install_destination() -> Result<PathBuf, String> {
246    let explicit_install_dir = std::env::var_os("MACH_INSTALL_DIR")
247        .filter(|value| !value.is_empty())
248        .map(PathBuf::from);
249    let home = dirs::home_dir();
250    let current_exe = std::env::current_exe().ok();
251    let cargo_home = std::env::var_os("CARGO_HOME")
252        .filter(|value| !value.is_empty())
253        .map(PathBuf::from);
254    resolve_install_destination(
255        explicit_install_dir.as_deref(),
256        home.as_deref(),
257        current_exe.as_deref(),
258        cargo_home.as_deref(),
259    )
260}
261
262fn resolve_install_destination(
263    explicit_install_dir: Option<&Path>,
264    home: Option<&Path>,
265    current_exe: Option<&Path>,
266    cargo_home: Option<&Path>,
267) -> Result<PathBuf, String> {
268    if let Some(install_dir) = explicit_install_dir {
269        return Ok(install_dir.join("mach"));
270    }
271
272    let home = home.ok_or_else(|| "could not determine the install directory".to_string())?;
273    let cargo_bin = cargo_home
274        .map(Path::to_path_buf)
275        .unwrap_or_else(|| home.join(".cargo"))
276        .join("bin");
277    if current_exe.and_then(Path::parent) == Some(cargo_bin.as_path()) {
278        return Err("this mach executable is managed by Cargo; update it with \
279             'cargo install --locked mach-tui', or set MACH_INSTALL_DIR to install a release \
280             binary elsewhere"
281            .into());
282    }
283    Ok(home.join(".local/bin/mach"))
284}
285
286fn checksum_for_asset(manifest: &str, asset_name: &str) -> Result<String, String> {
287    let mut found = None;
288    for line in manifest.lines() {
289        let mut fields = line.split_whitespace();
290        let Some(digest) = fields.next() else {
291            continue;
292        };
293        let Some(name) = fields.next() else {
294            continue;
295        };
296        if name.trim_start_matches('*') != asset_name {
297            continue;
298        }
299        if fields.next().is_some() {
300            return Err(format!(
301                "{CHECKSUMS_ASSET} contains a malformed entry for {asset_name}"
302            ));
303        }
304        if found.is_some() {
305            return Err(format!(
306                "{CHECKSUMS_ASSET} contains duplicate entries for {asset_name}"
307            ));
308        }
309        if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
310            return Err(format!(
311                "{CHECKSUMS_ASSET} contains an invalid digest for {asset_name}"
312            ));
313        }
314        found = Some(digest.to_ascii_lowercase());
315    }
316    found.ok_or_else(|| format!("{CHECKSUMS_ASSET} has no entry for {asset_name}"))
317}
318
319fn download_verified_binary(
320    url: &str,
321    expected_sha: &str,
322    destination: &Path,
323    progress: impl FnMut(DownloadProgress),
324) -> Result<(), String> {
325    let config = ureq::Agent::config_builder()
326        .timeout_global(Some(DOWNLOAD_TIMEOUT))
327        .build();
328    let agent: ureq::Agent = config.into();
329    let mut response = agent
330        .get(url)
331        .header("User-Agent", USER_AGENT)
332        .header("Accept", "application/octet-stream")
333        .call()
334        .map_err(map_download_err)?;
335    let total = response.body().content_length();
336    if total.is_some_and(|total| total > MAX_BINARY_BYTES) {
337        return Err(format!(
338            "release binary exceeds the {} MiB safety limit",
339            MAX_BINARY_BYTES / 1024 / 1024
340        ));
341    }
342    write_verified_binary(
343        response.body_mut().as_reader(),
344        expected_sha,
345        destination,
346        total,
347        progress,
348    )
349}
350
351fn write_verified_binary<R: Read>(
352    mut source: R,
353    expected_sha: &str,
354    destination: &Path,
355    expected_total: Option<u64>,
356    mut progress: impl FnMut(DownloadProgress),
357) -> Result<(), String> {
358    #[cfg(not(unix))]
359    return Err("self-update is supported only on Unix platforms".into());
360
361    #[cfg(unix)]
362    {
363        let parent = destination
364            .parent()
365            .filter(|path| !path.as_os_str().is_empty())
366            .ok_or_else(|| "install destination has no parent directory".to_string())?;
367        fs::create_dir_all(parent).map_err(|e| {
368            format!(
369                "could not create install directory {}: {e}",
370                parent.display()
371            )
372        })?;
373        let parent_dir = File::open(parent)
374            .map_err(|e| format!("could not open install directory {}: {e}", parent.display()))?;
375        let temp_path = parent.join(format!(".mach.{}.tmp", uuid::Uuid::new_v4()));
376        let mut temp_file = OpenOptions::new()
377            .write(true)
378            .create_new(true)
379            .open(&temp_path)
380            .map_err(|e| format!("could not create temporary binary: {e}"))?;
381
382        progress(DownloadProgress {
383            downloaded: 0,
384            total: expected_total,
385        });
386
387        let write_result = (|| -> Result<(), String> {
388            let mut hasher = Sha256::new();
389            let mut downloaded = 0_u64;
390            let mut buffer = [0_u8; 64 * 1024];
391            loop {
392                let read = source
393                    .read(&mut buffer)
394                    .map_err(|e| format!("could not read release binary: {e}"))?;
395                if read == 0 {
396                    break;
397                }
398                downloaded = downloaded
399                    .checked_add(read as u64)
400                    .ok_or_else(|| "release binary is too large".to_string())?;
401                if downloaded > MAX_BINARY_BYTES {
402                    return Err(format!(
403                        "release binary exceeds the {} MiB safety limit",
404                        MAX_BINARY_BYTES / 1024 / 1024
405                    ));
406                }
407                hasher.update(&buffer[..read]);
408                temp_file
409                    .write_all(&buffer[..read])
410                    .map_err(|e| format!("could not write temporary binary: {e}"))?;
411                progress(DownloadProgress {
412                    downloaded,
413                    total: expected_total,
414                });
415            }
416
417            let actual_sha = format!("{:x}", hasher.finalize());
418            if actual_sha != expected_sha {
419                return Err(format!(
420                    "SHA-256 verification failed (expected {expected_sha}, got {actual_sha})"
421                ));
422            }
423            temp_file
424                .set_permissions(fs::Permissions::from_mode(0o755))
425                .map_err(|e| format!("could not mark temporary binary executable: {e}"))?;
426            temp_file
427                .sync_all()
428                .map_err(|e| format!("could not sync temporary binary: {e}"))?;
429            Ok(())
430        })();
431        drop(temp_file);
432
433        if let Err(error) = write_result {
434            let _ = fs::remove_file(&temp_path);
435            return Err(error);
436        }
437        if let Err(error) = fs::rename(&temp_path, destination) {
438            let _ = fs::remove_file(&temp_path);
439            return Err(format!(
440                "could not replace {} atomically: {error}",
441                destination.display()
442            ));
443        }
444        parent_dir
445            .sync_all()
446            .map_err(|e| format!("could not sync install directory {}: {e}", parent.display()))?;
447        Ok(())
448    }
449}
450
451#[cfg(test)]
452fn sha256_hex(bytes: &[u8]) -> String {
453    format!("{:x}", Sha256::digest(bytes))
454}
455
456#[derive(Debug, Deserialize)]
457struct GhRelease {
458    tag_name: String,
459    #[serde(default)]
460    html_url: String,
461    #[serde(default)]
462    prerelease: bool,
463    #[serde(default)]
464    draft: bool,
465    #[serde(default)]
466    assets: Vec<GhAsset>,
467}
468
469impl GhRelease {
470    fn asset_url(&self, name: &str) -> Option<&str> {
471        self.assets
472            .iter()
473            .find(|asset| asset.name == name && !asset.browser_download_url.is_empty())
474            .map(|asset| asset.browser_download_url.as_str())
475    }
476}
477
478#[derive(Debug, Deserialize)]
479struct GhAsset {
480    name: String,
481    #[serde(default)]
482    browser_download_url: String,
483}
484
485fn http_get(url: &str) -> Result<String, String> {
486    http_get_text(url, TIMEOUT, "application/vnd.github+json", map_ureq_err)
487}
488
489fn http_get_text(
490    url: &str,
491    timeout: Duration,
492    accept: &str,
493    map_error: fn(ureq::Error) -> String,
494) -> Result<String, String> {
495    let config = ureq::Agent::config_builder()
496        .timeout_global(Some(timeout))
497        .build();
498    let agent: ureq::Agent = config.into();
499    let mut response = agent
500        .get(url)
501        .header("User-Agent", USER_AGENT)
502        .header("Accept", accept)
503        .call()
504        .map_err(map_error)?;
505    read_bounded_text(response.body_mut().as_reader(), MAX_TEXT_BYTES)
506}
507
508fn read_bounded_text<R: Read>(source: R, max_bytes: u64) -> Result<String, String> {
509    let mut bytes = Vec::new();
510    source
511        .take(max_bytes.saturating_add(1))
512        .read_to_end(&mut bytes)
513        .map_err(|e| format!("could not read response: {e}"))?;
514    if bytes.len() as u64 > max_bytes {
515        return Err(format!("response exceeds the {max_bytes}-byte limit"));
516    }
517    String::from_utf8(bytes).map_err(|e| format!("response is not valid UTF-8: {e}"))
518}
519
520fn map_download_err(error: ureq::Error) -> String {
521    match error {
522        ureq::Error::StatusCode(code) => format!("download HTTP {code}"),
523        other => format!("download failed: {other}"),
524    }
525}
526
527fn parse_stable_tag(tag: &str) -> Option<Version> {
528    if tag != tag.trim() {
529        return None;
530    }
531    let tag = tag.trim();
532    let normalized = tag.strip_prefix('v').unwrap_or(tag);
533    let version = Version::parse(normalized).ok()?;
534    if !version.pre.is_empty() || !version.build.is_empty() || normalized != version.to_string() {
535        return None;
536    }
537    Some(version)
538}
539
540fn map_ureq_err(e: ureq::Error) -> String {
541    match e {
542        ureq::Error::StatusCode(404) => {
543            "no GitHub releases yet — publish one, or install from git".into()
544        }
545        ureq::Error::StatusCode(code) => format!("GitHub API HTTP {code}"),
546        other => format!("network error: {other}"),
547    }
548}
549
550/// Strip one conventional leading `v` and whitespace.
551pub fn normalize_tag(tag: &str) -> String {
552    let tag = tag.trim();
553    tag.strip_prefix('v').unwrap_or(tag).to_string()
554}
555
556/// True when `latest` is a higher semantic version than `current`.
557pub fn is_newer(latest: &str, current: &str) -> Option<bool> {
558    let a = Version::parse(&normalize_tag(latest)).ok()?;
559    let b = Version::parse(&normalize_tag(current)).ok()?;
560    Some(a > b)
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    fn release(tag: &str, prerelease: bool, assets: &[(&str, &str)]) -> GhRelease {
568        GhRelease {
569            tag_name: tag.into(),
570            html_url: format!("https://github.test/releases/tag/{tag}"),
571            prerelease,
572            draft: false,
573            assets: assets
574                .iter()
575                .map(|(name, url)| GhAsset {
576                    name: (*name).into(),
577                    browser_download_url: (*url).into(),
578                })
579                .collect(),
580        }
581    }
582
583    fn valid_install_result() -> CheckResult {
584        let current = Version::parse(current_version()).unwrap();
585        let latest = Version::new(
586            current.major,
587            current.minor,
588            current.patch.checked_add(1).unwrap(),
589        );
590        let tag = format!("v{latest}");
591        let asset_name = current_asset_name().unwrap();
592        CheckResult {
593            current: current.to_string(),
594            latest: latest.to_string(),
595            tag: tag.clone(),
596            newer: true,
597            prerelease: false,
598            release_url: format!("https://github.test/releases/tag/{tag}"),
599            asset_url: release_asset_url(&tag, &asset_name),
600            checksums_url: release_asset_url(&tag, CHECKSUMS_ASSET),
601            asset_name,
602        }
603    }
604
605    #[test]
606    fn normalizes_v_prefix() {
607        assert_eq!(normalize_tag("v1.2.3"), "1.2.3");
608        assert_eq!(normalize_tag(" 1.0.0 "), "1.0.0");
609    }
610
611    #[test]
612    fn compares_semver() {
613        assert_eq!(is_newer("0.2.0", "0.1.0"), Some(true));
614        assert_eq!(is_newer("0.1.0", "0.1.0"), Some(false));
615        assert_eq!(is_newer("0.1.0", "0.2.0"), Some(false));
616        assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true));
617        assert_eq!(is_newer("0.1.1-rc.1", "0.1.0"), Some(true));
618    }
619
620    #[test]
621    fn stable_release_tags_must_be_canonical_and_not_prereleases() {
622        assert_eq!(parse_stable_tag("v1.2.3").unwrap().to_string(), "1.2.3");
623        assert!(parse_stable_tag("1.2.3+build.4").is_none());
624        assert!(parse_stable_tag("v01.2.3").is_none());
625        assert!(parse_stable_tag("v1.2.3-rc.1").is_none());
626        assert!(parse_stable_tag(" v1.2.3").is_none());
627    }
628
629    #[test]
630    fn install_hint_prefers_verified_self_update() {
631        let r = CheckResult {
632            current: "0.1.0".into(),
633            latest: "0.1.0".into(),
634            tag: "v0.1.0".into(),
635            newer: false,
636            prerelease: false,
637            release_url: String::new(),
638            asset_name: "mach-aarch64-apple-darwin".into(),
639            asset_url: "https://example.test/mach".into(),
640            checksums_url: "https://example.test/SHA256SUMS".into(),
641        };
642        let h = r.install_hint();
643        assert!(h.contains("mach update --install"));
644        assert!(h.contains("cargo install --locked mach-tui"));
645        assert!(!h.contains("curl"));
646    }
647
648    #[test]
649    fn cargo_managed_binary_requires_cargo_or_an_explicit_release_destination() {
650        let home = Path::new("/home/alice");
651        let cargo_home = home.join(".cargo");
652        let current_exe = cargo_home.join("bin/mach");
653
654        let error = resolve_install_destination(None, Some(home), Some(&current_exe), None)
655            .expect_err("a Cargo-managed executable must not create a shadow release install");
656        assert!(error.contains("Cargo"));
657        assert!(error.contains("cargo install --locked mach-tui"));
658
659        assert_eq!(
660            resolve_install_destination(
661                Some(Path::new("/opt/mach/bin")),
662                Some(home),
663                Some(&current_exe),
664                None,
665            )
666            .unwrap(),
667            PathBuf::from("/opt/mach/bin/mach"),
668            "an explicit destination is an intentional ownership change"
669        );
670
671        let custom_cargo_home = Path::new("/srv/cargo");
672        let custom_exe = custom_cargo_home.join("bin/mach");
673        assert!(
674            resolve_install_destination(
675                None,
676                Some(home),
677                Some(&custom_exe),
678                Some(custom_cargo_home),
679            )
680            .is_err(),
681            "CARGO_HOME must participate in ownership detection"
682        );
683    }
684
685    #[test]
686    fn selector_ignores_legacy_prereleases_and_binds_required_assets() {
687        let releases = vec![
688            release("v1.21.9", false, &[]),
689            release(
690                "v2.0.0-rc.1",
691                false,
692                &[
693                    ("mach-x86_64-unknown-linux-gnu", "https://bad/tagged-rc"),
694                    (CHECKSUMS_ASSET, "https://bad/tagged-rc-sums"),
695                ],
696            ),
697            release(
698                "v0.2.0-rc.1",
699                true,
700                &[
701                    ("mach-x86_64-unknown-linux-gnu", "https://bad/rc"),
702                    (CHECKSUMS_ASSET, "https://bad/rc-sums"),
703                ],
704            ),
705            release(
706                "v0.1.2",
707                false,
708                &[
709                    ("mach-x86_64-unknown-linux-gnu", "https://good/mach"),
710                    (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
711                ],
712            ),
713        ];
714
715        let selected = select_release(&releases, "mach-x86_64-unknown-linux-gnu")
716            .expect("stable release with both assets");
717
718        assert_eq!(selected.version.to_string(), "0.1.2");
719        assert_eq!(selected.tag, "v0.1.2");
720        assert_eq!(selected.asset_url, "https://good/mach");
721        assert_eq!(selected.checksums_url, "https://good/SHA256SUMS");
722    }
723
724    #[test]
725    fn selector_allows_a_legitimate_major_upgrade() {
726        let releases = vec![release(
727            "v1.0.0",
728            false,
729            &[
730                ("mach-aarch64-apple-darwin", "https://good/mach"),
731                (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
732            ],
733        )];
734
735        let selected =
736            select_release(&releases, "mach-aarch64-apple-darwin").expect("major upgrade");
737        assert_eq!(selected.version.to_string(), "1.0.0");
738    }
739
740    #[test]
741    fn selector_still_returns_the_latest_release_when_this_build_is_ahead() {
742        let releases = vec![release(
743            "v0.9.0",
744            false,
745            &[
746                ("mach-aarch64-apple-darwin", "https://good/mach"),
747                (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
748            ],
749        )];
750
751        let selected = select_release(&releases, "mach-aarch64-apple-darwin")
752            .expect("an older eligible release is still the latest published release");
753        assert_eq!(selected.version.to_string(), "0.9.0");
754        assert_eq!(
755            is_newer(&selected.version.to_string(), "1.0.0"),
756            Some(false)
757        );
758    }
759
760    #[test]
761    fn selector_rejects_releases_missing_the_binary_or_checksum_manifest() {
762        let releases = vec![
763            release(
764                "v0.3.0",
765                false,
766                &[(CHECKSUMS_ASSET, "https://bad/only-sums")],
767            ),
768            release(
769                "v0.2.0",
770                false,
771                &[("mach-x86_64-unknown-linux-gnu", "https://bad/only-bin")],
772            ),
773        ];
774
775        assert!(select_release(&releases, "mach-x86_64-unknown-linux-gnu").is_none());
776    }
777
778    #[test]
779    fn checksum_parser_requires_one_exact_valid_asset_entry() {
780        let digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
781        assert_eq!(
782            checksum_for_asset(
783                &format!("{digest}  mach-aarch64-apple-darwin\n"),
784                "mach-aarch64-apple-darwin",
785            )
786            .unwrap(),
787            digest,
788        );
789        assert!(checksum_for_asset(&format!("{digest}  mach-other\n"), "mach").is_err());
790        assert!(checksum_for_asset(&format!("{digest}  mach\n{digest}  mach\n"), "mach",).is_err());
791        assert!(checksum_for_asset(&format!("{digest}  mach extra\n"), "mach").is_err());
792    }
793
794    #[test]
795    fn installer_rejects_urls_not_bound_to_the_selected_tag_and_asset() {
796        let mut result = valid_install_result();
797        validate_install_info(&result).unwrap();
798
799        result.asset_url.push_str("?wrong-release");
800        assert!(validate_install_info(&result).is_err());
801    }
802
803    #[test]
804    fn installer_rejects_stale_or_non_update_check_results() {
805        let mut stale = valid_install_result();
806        stale.current = "0.0.0".into();
807        assert!(
808            validate_install_info(&stale)
809                .unwrap_err()
810                .contains("produced for")
811        );
812
813        let mut not_newer = valid_install_result();
814        not_newer.newer = false;
815        assert!(
816            validate_install_info(&not_newer)
817                .unwrap_err()
818                .contains("not newer")
819        );
820    }
821
822    #[test]
823    fn installer_rejects_reinstalls_and_downgrades() {
824        let mut reinstall = valid_install_result();
825        reinstall.latest = current_version().into();
826        reinstall.tag = format!("v{}", current_version());
827        reinstall.asset_url = release_asset_url(&reinstall.tag, &reinstall.asset_name);
828        reinstall.checksums_url = release_asset_url(&reinstall.tag, CHECKSUMS_ASSET);
829        assert!(
830            validate_install_info(&reinstall)
831                .unwrap_err()
832                .contains("must move forward")
833        );
834
835        let current = Version::parse(current_version()).unwrap();
836        let lower = Version::new(0, 0, 0);
837        assert!(lower < current, "test package version must be above 0.0.0");
838        let mut downgrade = valid_install_result();
839        downgrade.latest = lower.to_string();
840        downgrade.tag = format!("v{lower}");
841        downgrade.asset_url = release_asset_url(&downgrade.tag, &downgrade.asset_name);
842        downgrade.checksums_url = release_asset_url(&downgrade.tag, CHECKSUMS_ASSET);
843        assert!(
844            validate_install_info(&downgrade)
845                .unwrap_err()
846                .contains("must move forward")
847        );
848    }
849
850    #[test]
851    fn text_responses_are_bounded() {
852        assert_eq!(
853            read_bounded_text(std::io::Cursor::new(b"four"), 4).unwrap(),
854            "four"
855        );
856        assert!(
857            read_bounded_text(std::io::Cursor::new(b"oversized"), 4)
858                .unwrap_err()
859                .contains("4-byte limit")
860        );
861    }
862
863    #[test]
864    fn verified_replace_preserves_the_existing_binary_on_hash_failure() {
865        let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
866        fs::create_dir(&dir).unwrap();
867        let destination = dir.join("mach");
868        fs::write(&destination, b"old binary").unwrap();
869
870        let error = write_verified_binary(
871            std::io::Cursor::new(b"corrupt download"),
872            &"0".repeat(64),
873            &destination,
874            None,
875            |_| {},
876        )
877        .unwrap_err();
878
879        assert!(error.contains("SHA-256"));
880        assert_eq!(fs::read(&destination).unwrap(), b"old binary");
881        fs::remove_dir_all(dir).unwrap();
882    }
883
884    #[test]
885    fn verified_replace_installs_an_executable_binary() {
886        let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
887        fs::create_dir(&dir).unwrap();
888        let destination = dir.join("mach");
889        let binary = b"verified binary";
890        let digest = sha256_hex(binary);
891
892        write_verified_binary(
893            std::io::Cursor::new(binary),
894            &digest,
895            &destination,
896            None,
897            |_| {},
898        )
899        .unwrap();
900
901        assert_eq!(fs::read(&destination).unwrap(), binary);
902        #[cfg(unix)]
903        {
904            use std::os::unix::fs::PermissionsExt;
905            assert_eq!(
906                fs::metadata(&destination).unwrap().permissions().mode() & 0o777,
907                0o755
908            );
909        }
910        fs::remove_dir_all(dir).unwrap();
911    }
912
913    #[test]
914    fn verified_replace_reports_monotonic_download_progress() {
915        let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
916        fs::create_dir(&dir).unwrap();
917        let destination = dir.join("mach");
918        let binary = vec![b'x'; 150_000];
919        let digest = sha256_hex(&binary);
920        let mut progress = Vec::new();
921
922        write_verified_binary(
923            std::io::Cursor::new(&binary),
924            &digest,
925            &destination,
926            Some(binary.len() as u64),
927            |event| progress.push(event),
928        )
929        .unwrap();
930
931        assert_eq!(
932            progress.first(),
933            Some(&DownloadProgress {
934                downloaded: 0,
935                total: Some(binary.len() as u64),
936            })
937        );
938        assert_eq!(
939            progress.last(),
940            Some(&DownloadProgress {
941                downloaded: binary.len() as u64,
942                total: Some(binary.len() as u64),
943            })
944        );
945        assert!(
946            progress
947                .windows(2)
948                .all(|pair| pair[0].downloaded <= pair[1].downloaded)
949        );
950        fs::remove_dir_all(dir).unwrap();
951    }
952}