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