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