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 mut response = download_response(url)?;
435    let total = response.body().content_length();
436    if total.is_some_and(|total| total > MAX_BINARY_BYTES) {
437        return Err(format!(
438            "release binary exceeds the {} MiB safety limit",
439            MAX_BINARY_BYTES / 1024 / 1024
440        ));
441    }
442    write_verified_binary(
443        response.body_mut().as_reader(),
444        expected_sha,
445        destination,
446        target_version,
447        total,
448        progress,
449    )
450}
451
452fn download_response(url: &str) -> Result<ureq::http::Response<ureq::Body>, String> {
453    let config = ureq::Agent::config_builder()
454        .timeout_global(Some(DOWNLOAD_TIMEOUT))
455        .build();
456    let agent: ureq::Agent = config.into();
457    agent
458        .get(url)
459        .header("User-Agent", USER_AGENT)
460        .header("Accept", "application/octet-stream")
461        .call()
462        .map_err(map_download_err)
463}
464
465fn write_verified_binary<R: Read>(
466    mut source: R,
467    expected_sha: &str,
468    destination: &Path,
469    target_version: &Version,
470    expected_total: Option<u64>,
471    mut progress: impl FnMut(DownloadProgress),
472) -> Result<(Version, InstallDisposition), String> {
473    #[cfg(not(unix))]
474    return Err("self-update is supported only on Unix platforms".into());
475
476    #[cfg(unix)]
477    {
478        if expected_sha.len() != 64 || !expected_sha.bytes().all(|byte| byte.is_ascii_hexdigit()) {
479            return Err("expected release digest is not a SHA-256 digest".into());
480        }
481        let expected_sha = expected_sha.to_ascii_lowercase();
482        let parent = destination
483            .parent()
484            .filter(|path| !path.as_os_str().is_empty())
485            .ok_or_else(|| "install destination has no parent directory".to_string())?;
486        fs::create_dir_all(parent).map_err(|e| {
487            format!(
488                "could not create install directory {}: {e}",
489                parent.display()
490            )
491        })?;
492        let parent_dir = File::open(parent)
493            .map_err(|e| format!("could not open install directory {}: {e}", parent.display()))?;
494        let temp_path = parent.join(format!(".mach.{}.tmp", uuid::Uuid::new_v4()));
495        let mut temp_file = OpenOptions::new()
496            .write(true)
497            .create_new(true)
498            .open(&temp_path)
499            .map_err(|e| format!("could not create temporary binary: {e}"))?;
500
501        progress(DownloadProgress {
502            downloaded: 0,
503            total: expected_total,
504        });
505
506        let write_result = (|| -> Result<String, String> {
507            let mut hasher = Sha256::new();
508            let mut downloaded = 0_u64;
509            let mut buffer = [0_u8; 64 * 1024];
510            loop {
511                let read = source
512                    .read(&mut buffer)
513                    .map_err(|e| format!("could not read release binary: {e}"))?;
514                if read == 0 {
515                    break;
516                }
517                downloaded = downloaded
518                    .checked_add(read as u64)
519                    .ok_or_else(|| "release binary is too large".to_string())?;
520                if downloaded > MAX_BINARY_BYTES {
521                    return Err(format!(
522                        "release binary exceeds the {} MiB safety limit",
523                        MAX_BINARY_BYTES / 1024 / 1024
524                    ));
525                }
526                hasher.update(&buffer[..read]);
527                temp_file
528                    .write_all(&buffer[..read])
529                    .map_err(|e| format!("could not write temporary binary: {e}"))?;
530                progress(DownloadProgress {
531                    downloaded,
532                    total: expected_total,
533                });
534            }
535
536            let actual_sha = format!("{:x}", hasher.finalize());
537            if actual_sha != expected_sha {
538                return Err(format!(
539                    "SHA-256 verification failed (expected {expected_sha}, got {actual_sha})"
540                ));
541            }
542            temp_file
543                .set_permissions(fs::Permissions::from_mode(0o755))
544                .map_err(|e| format!("could not mark temporary binary executable: {e}"))?;
545            temp_file
546                .sync_all()
547                .map_err(|e| format!("could not sync temporary binary: {e}"))?;
548            Ok(actual_sha)
549        })();
550        drop(temp_file);
551
552        let actual_sha = match write_result {
553            Ok(actual_sha) => actual_sha,
554            Err(error) => {
555                let _ = fs::remove_file(&temp_path);
556                return Err(error);
557            }
558        };
559
560        let install_result = (|| -> Result<(Version, InstallDisposition), String> {
561            let _lock = InstallLock::acquire(parent)?;
562            if let Some(installed_version) =
563                receipted_release_version(destination)?.filter(|version| version >= target_version)
564            {
565                return Ok((installed_version, InstallDisposition::AlreadyCurrent));
566            }
567
568            let (installed_version, receipt_update) =
569                record_release_version(parent, &actual_sha, target_version)?;
570            if let Err(error) = fs::rename(&temp_path, destination) {
571                let rollback_error = receipt_update.rollback().err();
572                let mut message = format!(
573                    "could not replace {} atomically: {error}",
574                    destination.display()
575                );
576                if let Some(rollback_error) = rollback_error {
577                    message.push_str(&format!(
578                        "; could not roll back release receipt: {rollback_error}"
579                    ));
580                }
581                return Err(message);
582            }
583            parent_dir.sync_all().map_err(|e| {
584                format!("could not sync install directory {}: {e}", parent.display())
585            })?;
586            Ok((installed_version, InstallDisposition::Installed))
587        })();
588
589        if temp_path.exists() {
590            let _ = fs::remove_file(&temp_path);
591        }
592        install_result
593    }
594}
595
596#[cfg(unix)]
597fn receipted_release_version(destination: &Path) -> Result<Option<Version>, String> {
598    let Some(parent) = destination.parent() else {
599        return Ok(None);
600    };
601    let receipt_dir = parent.join(RELEASE_RECEIPT_DIR);
602    if !receipt_dir.is_dir() || !destination.is_file() {
603        return Ok(None);
604    }
605    let digest = sha256_file(destination)?;
606    let receipt = receipt_dir.join(digest);
607    if !receipt.is_file() {
608        return Ok(None);
609    }
610    read_receipt_version(&receipt).map(Some)
611}
612
613#[cfg(not(unix))]
614fn receipted_release_version(_destination: &Path) -> Result<Option<Version>, String> {
615    Ok(None)
616}
617
618#[cfg(unix)]
619fn sha256_file(path: &Path) -> Result<String, String> {
620    let metadata = fs::metadata(path)
621        .map_err(|e| format!("could not inspect installed binary {}: {e}", path.display()))?;
622    if metadata.len() > MAX_BINARY_BYTES {
623        return Err(format!(
624            "installed binary {} exceeds the {} MiB safety limit",
625            path.display(),
626            MAX_BINARY_BYTES / 1024 / 1024
627        ));
628    }
629    let mut file = File::open(path)
630        .map_err(|e| format!("could not open installed binary {}: {e}", path.display()))?;
631    let mut hasher = Sha256::new();
632    let mut buffer = [0_u8; 64 * 1024];
633    loop {
634        let read = file
635            .read(&mut buffer)
636            .map_err(|e| format!("could not read installed binary {}: {e}", path.display()))?;
637        if read == 0 {
638            break;
639        }
640        hasher.update(&buffer[..read]);
641    }
642    Ok(format!("{:x}", hasher.finalize()))
643}
644
645#[cfg(unix)]
646fn read_receipt_version(path: &Path) -> Result<Version, String> {
647    let file = File::open(path)
648        .map_err(|e| format!("could not open release receipt {}: {e}", path.display()))?;
649    let text = read_bounded_text(file, 128)
650        .map_err(|e| format!("invalid release receipt {}: {e}", path.display()))?;
651    let value = text
652        .strip_suffix('\n')
653        .filter(|value| !value.is_empty() && !value.contains(['\r', '\n']))
654        .ok_or_else(|| format!("invalid release receipt {}", path.display()))?;
655    let version = Version::parse(value)
656        .map_err(|e| format!("invalid release receipt {}: {e}", path.display()))?;
657    if !is_canonical_stable_version(value, &version) {
658        return Err(format!("invalid release receipt {}", path.display()));
659    }
660    Ok(version)
661}
662
663#[cfg(unix)]
664fn record_release_version(
665    parent: &Path,
666    digest: &str,
667    target_version: &Version,
668) -> Result<(Version, ReceiptUpdate), String> {
669    let receipt_dir = parent.join(RELEASE_RECEIPT_DIR);
670    fs::create_dir_all(&receipt_dir).map_err(|e| {
671        format!(
672            "could not create release receipt directory {}: {e}",
673            receipt_dir.display()
674        )
675    })?;
676    let receipt = receipt_dir.join(digest);
677    let previous_version = if receipt.is_file() {
678        let recorded = read_receipt_version(&receipt)?;
679        if recorded >= *target_version {
680            return Ok((recorded, ReceiptUpdate::Unchanged));
681        }
682        Some(recorded)
683    } else {
684        None
685    };
686
687    write_release_receipt(parent, &receipt, target_version)?;
688    let update = match previous_version {
689        Some(previous) => ReceiptUpdate::Replaced { receipt, previous },
690        None => ReceiptUpdate::Created(receipt),
691    };
692    Ok((target_version.clone(), update))
693}
694
695#[cfg(unix)]
696fn write_release_receipt(parent: &Path, receipt: &Path, version: &Version) -> Result<(), String> {
697    let receipt_dir = receipt
698        .parent()
699        .ok_or_else(|| "release receipt has no parent directory".to_string())?;
700    let temp_path = receipt_dir.join(format!(".receipt.{}.tmp", uuid::Uuid::new_v4()));
701    let write_result = (|| -> Result<(), String> {
702        let mut file = OpenOptions::new()
703            .write(true)
704            .create_new(true)
705            .open(&temp_path)
706            .map_err(|e| format!("could not create release receipt: {e}"))?;
707        writeln!(file, "{version}").map_err(|e| format!("could not write release receipt: {e}"))?;
708        file.sync_all()
709            .map_err(|e| format!("could not sync release receipt: {e}"))?;
710        fs::rename(&temp_path, receipt)
711            .map_err(|e| format!("could not publish release receipt: {e}"))?;
712        File::open(receipt_dir)
713            .and_then(|directory| directory.sync_all())
714            .map_err(|e| format!("could not sync release receipt directory: {e}"))?;
715        File::open(parent)
716            .and_then(|directory| directory.sync_all())
717            .map_err(|e| format!("could not sync install directory {}: {e}", parent.display()))?;
718        Ok(())
719    })();
720    if write_result.is_err() && temp_path.exists() {
721        let _ = fs::remove_file(&temp_path);
722    }
723    write_result
724}
725
726#[cfg(unix)]
727enum ReceiptUpdate {
728    Unchanged,
729    Created(PathBuf),
730    Replaced { receipt: PathBuf, previous: Version },
731}
732
733#[cfg(unix)]
734impl ReceiptUpdate {
735    fn rollback(self) -> Result<(), String> {
736        let receipt = match self {
737            Self::Unchanged => return Ok(()),
738            Self::Replaced { receipt, previous } => {
739                let parent = receipt
740                    .parent()
741                    .and_then(Path::parent)
742                    .ok_or_else(|| "release receipt directory has no parent".to_string())?;
743                return write_release_receipt(parent, &receipt, &previous);
744            }
745            Self::Created(receipt) => receipt,
746        };
747        let receipt_dir = receipt
748            .parent()
749            .ok_or_else(|| "release receipt has no parent directory".to_string())?;
750        let parent = receipt_dir
751            .parent()
752            .ok_or_else(|| "release receipt directory has no parent".to_string())?;
753        fs::remove_file(&receipt)
754            .map_err(|e| format!("could not remove {}: {e}", receipt.display()))?;
755        File::open(receipt_dir)
756            .and_then(|directory| directory.sync_all())
757            .map_err(|e| format!("could not sync release receipt directory: {e}"))?;
758        File::open(parent)
759            .and_then(|directory| directory.sync_all())
760            .map_err(|e| format!("could not sync install directory {}: {e}", parent.display()))
761    }
762}
763
764#[cfg(unix)]
765struct InstallLock {
766    path: PathBuf,
767    owner_record: String,
768}
769
770#[cfg(unix)]
771impl InstallLock {
772    fn acquire(parent: &Path) -> Result<Self, String> {
773        let path = parent.join(INSTALL_LOCK_DIR);
774        let started = Instant::now();
775        loop {
776            match fs::create_dir(&path) {
777                Ok(()) => {
778                    let timestamp = SystemTime::now()
779                        .duration_since(UNIX_EPOCH)
780                        .unwrap_or_default()
781                        .as_secs();
782                    let owner_record = format!("{timestamp} {}\n", uuid::Uuid::new_v4());
783                    let owner_path = path.join(INSTALL_LOCK_OWNER);
784                    let initialize = (|| -> Result<(), String> {
785                        let mut owner = OpenOptions::new()
786                            .write(true)
787                            .create_new(true)
788                            .open(&owner_path)
789                            .map_err(|e| format!("could not create install lock owner: {e}"))?;
790                        owner
791                            .write_all(owner_record.as_bytes())
792                            .map_err(|e| format!("could not write install lock owner: {e}"))?;
793                        owner
794                            .sync_all()
795                            .map_err(|e| format!("could not sync install lock owner: {e}"))?;
796                        File::open(&path)
797                            .and_then(|directory| directory.sync_all())
798                            .map_err(|e| format!("could not sync install lock: {e}"))?;
799                        Ok(())
800                    })();
801                    if let Err(error) = initialize {
802                        let _ = fs::remove_file(owner_path);
803                        let _ = fs::remove_dir(&path);
804                        return Err(error);
805                    }
806                    return Ok(Self { path, owner_record });
807                }
808                Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
809                Err(error) => {
810                    return Err(format!(
811                        "could not acquire install lock {}: {error}",
812                        path.display()
813                    ));
814                }
815            }
816            if started.elapsed() >= INSTALL_LOCK_WAIT {
817                return Err(format!(
818                    "timed out waiting for another installer holding {}; if no installer is \
819                     running, remove this stale lock directory",
820                    path.display()
821                ));
822            }
823            thread::sleep(INSTALL_LOCK_POLL);
824        }
825    }
826}
827
828#[cfg(unix)]
829impl Drop for InstallLock {
830    fn drop(&mut self) {
831        let owner = self.path.join(INSTALL_LOCK_OWNER);
832        if fs::read_to_string(&owner).ok().as_deref() == Some(self.owner_record.as_str()) {
833            let _ = fs::remove_file(owner);
834            let _ = fs::remove_dir(&self.path);
835        }
836    }
837}
838
839#[cfg(test)]
840fn sha256_hex(bytes: &[u8]) -> String {
841    format!("{:x}", Sha256::digest(bytes))
842}
843
844#[derive(Debug, Deserialize)]
845struct GhRelease {
846    tag_name: String,
847    #[serde(default)]
848    html_url: String,
849    #[serde(default)]
850    prerelease: bool,
851    #[serde(default)]
852    draft: bool,
853    #[serde(default)]
854    assets: Vec<GhAsset>,
855}
856
857impl GhRelease {
858    fn asset_url(&self, name: &str) -> Option<&str> {
859        self.assets
860            .iter()
861            .find(|asset| asset.name == name && !asset.browser_download_url.is_empty())
862            .map(|asset| asset.browser_download_url.as_str())
863    }
864}
865
866#[derive(Debug, Deserialize)]
867struct GhAsset {
868    name: String,
869    #[serde(default)]
870    browser_download_url: String,
871}
872
873type ReleaseDocument = Conditional<String>;
874
875fn fetch_releases(url: &str, etag: Option<&str>) -> Result<ReleaseDocument, CheckFailure> {
876    let config = ureq::Agent::config_builder()
877        .timeout_global(Some(TIMEOUT))
878        .http_status_as_error(false)
879        .build();
880    let agent: ureq::Agent = config.into();
881    let mut request = agent
882        .get(url)
883        .header("User-Agent", USER_AGENT)
884        .header("Accept", "application/vnd.github+json");
885    if let Some(etag) = etag {
886        request = request.header("If-None-Match", etag);
887    }
888    let mut response = request
889        .call()
890        .map_err(|error| CheckFailure::new(map_ureq_err(error)))?;
891    let status = response.status().as_u16();
892    if status == 304 {
893        return Ok(ReleaseDocument::NotModified);
894    }
895    if status != 200 {
896        let now = Utc::now().timestamp();
897        let retry_at = response
898            .headers()
899            .get("Retry-After")
900            .and_then(|value| value.to_str().ok())
901            .and_then(|value| parse_retry_after(value, now))
902            .or_else(|| {
903                let remaining = response
904                    .headers()
905                    .get("X-RateLimit-Remaining")
906                    .and_then(|value| value.to_str().ok());
907                (remaining == Some("0"))
908                    .then(|| {
909                        response
910                            .headers()
911                            .get("X-RateLimit-Reset")
912                            .and_then(|value| value.to_str().ok())
913                            .and_then(parse_nonnegative_decimal)
914                    })
915                    .flatten()
916            });
917        let message = if status == 404 {
918            "no GitHub releases yet — publish one, or install from git".into()
919        } else {
920            format!("GitHub API HTTP {status}")
921        };
922        return Err(CheckFailure { message, retry_at });
923    }
924    let response_etag = response
925        .headers()
926        .get("ETag")
927        .and_then(|value| value.to_str().ok())
928        .map(str::to_owned);
929    let body = read_bounded_text(response.body_mut().as_reader(), MAX_TEXT_BYTES)
930        .map_err(CheckFailure::new)?;
931    Ok(ReleaseDocument::Modified {
932        value: body,
933        etag: response_etag,
934    })
935}
936
937fn parse_retry_after(value: &str, now: i64) -> Option<i64> {
938    let value = value.trim();
939    if let Some(seconds) = parse_nonnegative_decimal(value) {
940        return Some(now.saturating_add(seconds));
941    }
942    let timestamp = httpdate::parse_http_date(value).ok()?;
943    let seconds = timestamp.duration_since(UNIX_EPOCH).ok()?.as_secs();
944    Some(i64::try_from(seconds).unwrap_or(i64::MAX))
945}
946
947fn parse_nonnegative_decimal(value: &str) -> Option<i64> {
948    let value = value.trim();
949    if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
950        return None;
951    }
952    Some(value.bytes().fold(0_i64, |number, byte| {
953        number
954            .saturating_mul(10)
955            .saturating_add(i64::from(byte - b'0'))
956    }))
957}
958
959fn download_checksum_manifest(url: &str) -> Result<String, String> {
960    let mut response = download_response(url)?;
961    read_bounded_text(response.body_mut().as_reader(), MAX_TEXT_BYTES)
962}
963
964fn read_bounded_text<R: Read>(source: R, max_bytes: u64) -> Result<String, String> {
965    let mut bytes = Vec::new();
966    source
967        .take(max_bytes.saturating_add(1))
968        .read_to_end(&mut bytes)
969        .map_err(|e| format!("could not read response: {e}"))?;
970    if bytes.len() as u64 > max_bytes {
971        return Err(format!("response exceeds the {max_bytes}-byte limit"));
972    }
973    String::from_utf8(bytes).map_err(|e| format!("response is not valid UTF-8: {e}"))
974}
975
976fn map_download_err(error: ureq::Error) -> String {
977    match error {
978        ureq::Error::StatusCode(code) => format!("download HTTP {code}"),
979        other => format!("download failed: {other}"),
980    }
981}
982
983fn parse_stable_tag(tag: &str) -> Option<Version> {
984    if tag != tag.trim() {
985        return None;
986    }
987    let tag = tag.trim();
988    let normalized = tag.strip_prefix('v').unwrap_or(tag);
989    parse_stable_version(normalized)
990}
991
992pub(crate) fn parse_stable_version(value: &str) -> Option<Version> {
993    let version = Version::parse(value).ok()?;
994    is_canonical_stable_version(value, &version).then_some(version)
995}
996
997fn is_canonical_stable_version(value: &str, version: &Version) -> bool {
998    version.pre.is_empty() && version.build.is_empty() && value == version.to_string()
999}
1000
1001fn map_ureq_err(e: ureq::Error) -> String {
1002    match e {
1003        ureq::Error::StatusCode(404) => {
1004            "no GitHub releases yet — publish one, or install from git".into()
1005        }
1006        ureq::Error::StatusCode(code) => format!("GitHub API HTTP {code}"),
1007        other => format!("network error: {other}"),
1008    }
1009}
1010
1011/// Strip one conventional leading `v` and whitespace.
1012pub fn normalize_tag(tag: &str) -> String {
1013    let tag = tag.trim();
1014    tag.strip_prefix('v').unwrap_or(tag).to_string()
1015}
1016
1017/// True when `latest` is a higher semantic version than `current`.
1018pub fn is_newer(latest: &str, current: &str) -> Option<bool> {
1019    let a = Version::parse(&normalize_tag(latest)).ok()?;
1020    let b = Version::parse(&normalize_tag(current)).ok()?;
1021    Some(a > b)
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026    use super::*;
1027    use std::net::TcpListener;
1028    use std::sync::mpsc;
1029
1030    fn serve_once(response: impl Into<String>) -> (String, mpsc::Receiver<String>) {
1031        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1032        let address = listener.local_addr().unwrap();
1033        let (request_tx, request_rx) = mpsc::channel();
1034        let response = response.into();
1035        std::thread::spawn(move || {
1036            let (mut stream, _) = listener.accept().unwrap();
1037            let mut request = Vec::new();
1038            let mut buffer = [0_u8; 1024];
1039            while !request.windows(4).any(|window| window == b"\r\n\r\n") {
1040                let read = stream.read(&mut buffer).unwrap();
1041                if read == 0 {
1042                    break;
1043                }
1044                request.extend_from_slice(&buffer[..read]);
1045            }
1046            let _ = request_tx.send(String::from_utf8(request).unwrap());
1047            stream.write_all(response.as_bytes()).unwrap();
1048        });
1049        (format!("http://{address}/releases"), request_rx)
1050    }
1051
1052    fn release(tag: &str, prerelease: bool, assets: &[(&str, &str)]) -> GhRelease {
1053        GhRelease {
1054            tag_name: tag.into(),
1055            html_url: format!("https://github.test/releases/tag/{tag}"),
1056            prerelease,
1057            draft: false,
1058            assets: assets
1059                .iter()
1060                .map(|(name, url)| GhAsset {
1061                    name: (*name).into(),
1062                    browser_download_url: (*url).into(),
1063                })
1064                .collect(),
1065        }
1066    }
1067
1068    fn valid_install_result() -> CheckResult {
1069        let current = Version::parse(current_version()).unwrap();
1070        let latest = Version::new(
1071            current.major,
1072            current.minor,
1073            current.patch.checked_add(1).unwrap(),
1074        );
1075        let tag = format!("v{latest}");
1076        let asset_name = current_asset_name().unwrap();
1077        CheckResult {
1078            current: current.to_string(),
1079            latest: latest.to_string(),
1080            tag: tag.clone(),
1081            newer: true,
1082            prerelease: false,
1083            release_url: format!("https://github.test/releases/tag/{tag}"),
1084            asset_url: release_asset_url(&tag, &asset_name),
1085            checksums_url: release_asset_url(&tag, CHECKSUMS_ASSET),
1086            asset_name,
1087        }
1088    }
1089
1090    #[test]
1091    fn normalizes_v_prefix() {
1092        assert_eq!(normalize_tag("v1.2.3"), "1.2.3");
1093        assert_eq!(normalize_tag(" 1.0.0 "), "1.0.0");
1094    }
1095
1096    #[test]
1097    fn compares_semver() {
1098        assert_eq!(is_newer("0.2.0", "0.1.0"), Some(true));
1099        assert_eq!(is_newer("0.1.0", "0.1.0"), Some(false));
1100        assert_eq!(is_newer("0.1.0", "0.2.0"), Some(false));
1101        assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true));
1102        assert_eq!(is_newer("0.1.1-rc.1", "0.1.0"), Some(true));
1103    }
1104
1105    #[test]
1106    fn conditional_release_request_reuses_etag_and_accepts_not_modified() {
1107        let (url, request) = serve_once(
1108            "HTTP/1.1 304 Not Modified\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1109        );
1110
1111        assert!(matches!(
1112            fetch_releases(&url, Some("\"release-etag\"")).unwrap(),
1113            ReleaseDocument::NotModified
1114        ));
1115        assert!(
1116            request
1117                .recv()
1118                .unwrap()
1119                .to_ascii_lowercase()
1120                .contains("if-none-match: \"release-etag\"")
1121        );
1122    }
1123
1124    #[test]
1125    fn modified_release_response_captures_the_new_etag() {
1126        let (url, _) = serve_once(
1127            "HTTP/1.1 200 OK\r\nETag: \"next-etag\"\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]",
1128        );
1129
1130        let ReleaseDocument::Modified { value: body, etag } = fetch_releases(&url, None).unwrap()
1131        else {
1132            panic!("a 200 response must carry a release document");
1133        };
1134        assert_eq!(body, "[]");
1135        assert_eq!(etag.as_deref(), Some("\"next-etag\""));
1136    }
1137
1138    #[test]
1139    fn rate_limited_release_request_preserves_retry_after() {
1140        let (url, _) = serve_once(
1141            "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 120\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1142        );
1143        let before = Utc::now().timestamp();
1144
1145        let error = fetch_releases(&url, None).expect_err("rate limit should fail the check");
1146
1147        assert_eq!(error.message, "GitHub API HTTP 429");
1148        assert!(error.retry_at.is_some_and(|retry_at| {
1149            retry_at >= before + 120 && retry_at <= Utc::now().timestamp() + 120
1150        }));
1151    }
1152
1153    #[test]
1154    fn retry_after_accepts_every_http_date_form() {
1155        let expected = 784_111_777;
1156        for value in [
1157            "Sun, 06 Nov 1994 08:49:37 GMT",
1158            "Sunday, 06-Nov-94 08:49:37 GMT",
1159            "Sun Nov  6 08:49:37 1994",
1160        ] {
1161            let (url, _) = serve_once(format!(
1162                "HTTP/1.1 429 Too Many Requests\r\nRetry-After: {value}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
1163            ));
1164
1165            let error = fetch_releases(&url, None).expect_err("rate limit should fail the check");
1166
1167            assert_eq!(error.retry_at, Some(expected), "failed to parse {value}");
1168        }
1169    }
1170
1171    #[test]
1172    fn rate_limit_reset_is_used_only_when_the_budget_is_exhausted() {
1173        let reset = Utc::now().timestamp() + 3_600;
1174        let (url, _) = serve_once(format!(
1175            "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"
1176        ));
1177        let error = fetch_releases(&url, None).expect_err("server error should fail the check");
1178        assert_eq!(error.retry_at, None);
1179
1180        let (url, _) = serve_once(format!(
1181            "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"
1182        ));
1183        let error = fetch_releases(&url, None).expect_err("rate limit should fail the check");
1184        assert_eq!(error.retry_at, Some(reset));
1185    }
1186
1187    #[test]
1188    fn stable_release_tags_must_be_canonical_and_not_prereleases() {
1189        assert_eq!(parse_stable_tag("v1.2.3").unwrap().to_string(), "1.2.3");
1190        assert!(parse_stable_tag("1.2.3+build.4").is_none());
1191        assert!(parse_stable_tag("v01.2.3").is_none());
1192        assert!(parse_stable_tag("v1.2.3-rc.1").is_none());
1193        assert!(parse_stable_tag(" v1.2.3").is_none());
1194    }
1195
1196    #[test]
1197    fn install_hint_prefers_verified_self_update() {
1198        let r = CheckResult {
1199            current: "0.1.0".into(),
1200            latest: "0.1.0".into(),
1201            tag: "v0.1.0".into(),
1202            newer: false,
1203            prerelease: false,
1204            release_url: String::new(),
1205            asset_name: "mach-aarch64-apple-darwin".into(),
1206            asset_url: "https://example.test/mach".into(),
1207            checksums_url: "https://example.test/SHA256SUMS".into(),
1208        };
1209        let h = r.install_hint();
1210        assert!(h.contains("mach update --install"));
1211        assert!(h.contains("cargo install --locked mach-tui"));
1212        assert!(!h.contains("curl"));
1213    }
1214
1215    #[test]
1216    fn cargo_managed_binary_names_its_update_command() {
1217        let home = Path::new("/home/alice");
1218        let cargo_home = home.join(".cargo");
1219        let current_exe = cargo_home.join("bin/mach");
1220
1221        let error = resolve_install_destination(None, Some(home), Some(&current_exe), None)
1222            .expect_err("a Cargo-managed executable must not create a shadow release install");
1223        assert_eq!(
1224            error,
1225            "Installation managed by Cargo: cargo install --locked mach-tui"
1226        );
1227
1228        assert_eq!(
1229            resolve_install_destination(
1230                Some(Path::new("/opt/mach/bin")),
1231                Some(home),
1232                Some(&current_exe),
1233                None,
1234            )
1235            .unwrap(),
1236            PathBuf::from("/opt/mach/bin/mach"),
1237            "an explicit destination is an intentional ownership change"
1238        );
1239
1240        let custom_cargo_home = Path::new("/srv/cargo");
1241        let custom_exe = custom_cargo_home.join("bin/mach");
1242        assert!(
1243            resolve_install_destination(
1244                None,
1245                Some(home),
1246                Some(&custom_exe),
1247                Some(custom_cargo_home),
1248            )
1249            .is_err(),
1250            "CARGO_HOME must participate in ownership detection"
1251        );
1252    }
1253
1254    #[test]
1255    fn externally_managed_binary_does_not_create_a_shadow_release_install() {
1256        let home = Path::new("/home/alice");
1257        let current_exe = Path::new("/opt/homebrew/bin/mach");
1258
1259        let error = resolve_install_destination(None, Some(home), Some(current_exe), None)
1260            .expect_err("an externally managed executable must not update a shadow destination");
1261
1262        assert!(error.contains("package manager"));
1263        assert!(error.contains("MACH_INSTALL_DIR"));
1264    }
1265
1266    #[test]
1267    fn cargo_install_root_is_detected_from_its_ownership_metadata() {
1268        let dir = std::env::temp_dir().join(format!("mach-cargo-root-{}", uuid::Uuid::new_v4()));
1269        let cargo_root = dir.join("custom-cargo-root");
1270        let bin = cargo_root.join("bin");
1271        fs::create_dir_all(&bin).unwrap();
1272        fs::write(cargo_root.join(".crates2.json"), b"{}").unwrap();
1273        let current_exe = bin.join("mach");
1274
1275        let error =
1276            resolve_install_destination(None, Some(dir.as_path()), Some(&current_exe), None)
1277                .expect_err(
1278                    "cargo install --root ownership must not create a shadow release install",
1279                );
1280
1281        assert!(error.contains("Cargo"));
1282        fs::remove_dir_all(dir).unwrap();
1283    }
1284
1285    #[test]
1286    fn release_receipt_disambiguates_a_cargo_root_at_the_default_destination() {
1287        let dir = std::env::temp_dir().join(format!("mach-cargo-default-{}", uuid::Uuid::new_v4()));
1288        let home = dir.join("home");
1289        let cargo_root = home.join(".local");
1290        let bin = cargo_root.join("bin");
1291        fs::create_dir_all(&bin).unwrap();
1292        fs::write(cargo_root.join(".crates2.json"), b"{}").unwrap();
1293        let current_exe = bin.join("mach");
1294        let binary = b"ambiguous default-path binary";
1295        fs::write(&current_exe, binary).unwrap();
1296
1297        let error = resolve_install_destination(None, Some(&home), Some(&current_exe), None)
1298            .expect_err("Cargo ownership must beat an unreceipted default path");
1299        assert!(error.contains("Cargo"));
1300
1301        let receipt_dir = bin.join(RELEASE_RECEIPT_DIR);
1302        fs::create_dir(&receipt_dir).unwrap();
1303        fs::write(receipt_dir.join(sha256_hex(binary)), b"1.2.3\n").unwrap();
1304        assert_eq!(
1305            resolve_install_destination(None, Some(&home), Some(&current_exe), None).unwrap(),
1306            current_exe,
1307            "a content-bound release receipt is stronger ownership evidence"
1308        );
1309
1310        fs::remove_dir_all(dir).unwrap();
1311    }
1312
1313    #[test]
1314    fn custom_release_destination_is_reused_only_with_a_matching_receipt() {
1315        let dir = std::env::temp_dir().join(format!("mach-release-root-{}", uuid::Uuid::new_v4()));
1316        let home = dir.join("home");
1317        let bin = dir.join("custom/bin");
1318        fs::create_dir_all(&bin).unwrap();
1319        let current_exe = bin.join("mach");
1320        let binary = b"checksum-verified release binary";
1321        fs::write(&current_exe, binary).unwrap();
1322        let receipt_dir = bin.join(RELEASE_RECEIPT_DIR);
1323        fs::create_dir(&receipt_dir).unwrap();
1324        fs::write(receipt_dir.join(sha256_hex(binary)), b"1.2.3\n").unwrap();
1325
1326        assert_eq!(
1327            resolve_install_destination(None, Some(&home), Some(&current_exe), None).unwrap(),
1328            current_exe
1329        );
1330        fs::remove_dir_all(dir).unwrap();
1331    }
1332
1333    #[test]
1334    fn selector_ignores_legacy_prereleases_and_binds_required_assets() {
1335        let releases = vec![
1336            release("v1.21.9", false, &[]),
1337            release(
1338                "v2.0.0-rc.1",
1339                false,
1340                &[
1341                    ("mach-x86_64-unknown-linux-gnu", "https://bad/tagged-rc"),
1342                    (CHECKSUMS_ASSET, "https://bad/tagged-rc-sums"),
1343                ],
1344            ),
1345            release(
1346                "v0.2.0-rc.1",
1347                true,
1348                &[
1349                    ("mach-x86_64-unknown-linux-gnu", "https://bad/rc"),
1350                    (CHECKSUMS_ASSET, "https://bad/rc-sums"),
1351                ],
1352            ),
1353            release(
1354                "v0.1.2",
1355                false,
1356                &[
1357                    ("mach-x86_64-unknown-linux-gnu", "https://good/mach"),
1358                    (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
1359                ],
1360            ),
1361        ];
1362
1363        let selected = select_release(&releases, "mach-x86_64-unknown-linux-gnu")
1364            .expect("stable release with both assets");
1365
1366        assert_eq!(selected.version.to_string(), "0.1.2");
1367        assert_eq!(selected.tag, "v0.1.2");
1368        assert_eq!(selected.asset_url, "https://good/mach");
1369        assert_eq!(selected.checksums_url, "https://good/SHA256SUMS");
1370    }
1371
1372    #[test]
1373    fn selector_allows_a_legitimate_major_upgrade() {
1374        let releases = vec![release(
1375            "v1.0.0",
1376            false,
1377            &[
1378                ("mach-aarch64-apple-darwin", "https://good/mach"),
1379                (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
1380            ],
1381        )];
1382
1383        let selected =
1384            select_release(&releases, "mach-aarch64-apple-darwin").expect("major upgrade");
1385        assert_eq!(selected.version.to_string(), "1.0.0");
1386    }
1387
1388    #[test]
1389    fn selector_still_returns_the_latest_release_when_this_build_is_ahead() {
1390        let releases = vec![release(
1391            "v0.9.0",
1392            false,
1393            &[
1394                ("mach-aarch64-apple-darwin", "https://good/mach"),
1395                (CHECKSUMS_ASSET, "https://good/SHA256SUMS"),
1396            ],
1397        )];
1398
1399        let selected = select_release(&releases, "mach-aarch64-apple-darwin")
1400            .expect("an older eligible release is still the latest published release");
1401        assert_eq!(selected.version.to_string(), "0.9.0");
1402        assert_eq!(
1403            is_newer(&selected.version.to_string(), "1.0.0"),
1404            Some(false)
1405        );
1406    }
1407
1408    #[test]
1409    fn selector_rejects_releases_missing_the_binary_or_checksum_manifest() {
1410        let releases = vec![
1411            release(
1412                "v0.3.0",
1413                false,
1414                &[(CHECKSUMS_ASSET, "https://bad/only-sums")],
1415            ),
1416            release(
1417                "v0.2.0",
1418                false,
1419                &[("mach-x86_64-unknown-linux-gnu", "https://bad/only-bin")],
1420            ),
1421        ];
1422
1423        assert!(select_release(&releases, "mach-x86_64-unknown-linux-gnu").is_none());
1424    }
1425
1426    #[test]
1427    fn checksum_parser_requires_one_exact_valid_asset_entry() {
1428        let digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1429        assert_eq!(
1430            checksum_for_asset(
1431                &format!("{digest}  mach-aarch64-apple-darwin\n"),
1432                "mach-aarch64-apple-darwin",
1433            )
1434            .unwrap(),
1435            digest,
1436        );
1437        assert!(checksum_for_asset(&format!("{digest}  mach-other\n"), "mach").is_err());
1438        assert!(checksum_for_asset(&format!("{digest}  mach\n{digest}  mach\n"), "mach",).is_err());
1439        assert!(checksum_for_asset(&format!("{digest}  mach extra\n"), "mach").is_err());
1440    }
1441
1442    #[test]
1443    fn installer_rejects_urls_not_bound_to_the_selected_tag_and_asset() {
1444        let mut result = valid_install_result();
1445        validate_install_info(&result).unwrap();
1446
1447        result.asset_url.push_str("?wrong-release");
1448        assert!(validate_install_info(&result).is_err());
1449    }
1450
1451    #[test]
1452    fn installer_rejects_stale_or_non_update_check_results() {
1453        let mut stale = valid_install_result();
1454        stale.current = "0.0.0".into();
1455        assert!(
1456            validate_install_info(&stale)
1457                .unwrap_err()
1458                .contains("produced for")
1459        );
1460
1461        let mut not_newer = valid_install_result();
1462        not_newer.newer = false;
1463        assert!(
1464            validate_install_info(&not_newer)
1465                .unwrap_err()
1466                .contains("not newer")
1467        );
1468    }
1469
1470    #[test]
1471    fn installer_rejects_reinstalls_and_downgrades() {
1472        let mut reinstall = valid_install_result();
1473        reinstall.latest = current_version().into();
1474        reinstall.tag = format!("v{}", current_version());
1475        reinstall.asset_url = release_asset_url(&reinstall.tag, &reinstall.asset_name);
1476        reinstall.checksums_url = release_asset_url(&reinstall.tag, CHECKSUMS_ASSET);
1477        assert!(
1478            validate_install_info(&reinstall)
1479                .unwrap_err()
1480                .contains("must move forward")
1481        );
1482
1483        let current = Version::parse(current_version()).unwrap();
1484        let lower = Version::new(0, 0, 0);
1485        assert!(lower < current, "test package version must be above 0.0.0");
1486        let mut downgrade = valid_install_result();
1487        downgrade.latest = lower.to_string();
1488        downgrade.tag = format!("v{lower}");
1489        downgrade.asset_url = release_asset_url(&downgrade.tag, &downgrade.asset_name);
1490        downgrade.checksums_url = release_asset_url(&downgrade.tag, CHECKSUMS_ASSET);
1491        assert!(
1492            validate_install_info(&downgrade)
1493                .unwrap_err()
1494                .contains("must move forward")
1495        );
1496    }
1497
1498    #[test]
1499    fn text_responses_are_bounded() {
1500        assert_eq!(
1501            read_bounded_text(std::io::Cursor::new(b"four"), 4).unwrap(),
1502            "four"
1503        );
1504        assert!(
1505            read_bounded_text(std::io::Cursor::new(b"oversized"), 4)
1506                .unwrap_err()
1507                .contains("4-byte limit")
1508        );
1509    }
1510
1511    #[test]
1512    fn verified_replace_preserves_the_existing_binary_on_hash_failure() {
1513        let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1514        fs::create_dir(&dir).unwrap();
1515        let destination = dir.join("mach");
1516        fs::write(&destination, b"old binary").unwrap();
1517
1518        let error = write_verified_binary(
1519            std::io::Cursor::new(b"corrupt download"),
1520            &"0".repeat(64),
1521            &destination,
1522            &Version::parse("1.0.0").unwrap(),
1523            None,
1524            |_| {},
1525        )
1526        .unwrap_err();
1527
1528        assert!(error.contains("SHA-256"));
1529        assert_eq!(fs::read(&destination).unwrap(), b"old binary");
1530        fs::remove_dir_all(dir).unwrap();
1531    }
1532
1533    #[test]
1534    fn verified_replace_installs_an_executable_binary() {
1535        let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1536        fs::create_dir(&dir).unwrap();
1537        let destination = dir.join("mach");
1538        let binary = b"verified binary";
1539        let digest = sha256_hex(binary);
1540        let version = Version::parse("1.2.3").unwrap();
1541
1542        let (installed_version, disposition) = write_verified_binary(
1543            std::io::Cursor::new(binary),
1544            &digest,
1545            &destination,
1546            &version,
1547            None,
1548            |_| {},
1549        )
1550        .unwrap();
1551
1552        assert_eq!(installed_version, version);
1553        assert_eq!(disposition, InstallDisposition::Installed);
1554        assert_eq!(fs::read(&destination).unwrap(), binary);
1555        assert_eq!(
1556            fs::read_to_string(dir.join(RELEASE_RECEIPT_DIR).join(&digest)).unwrap(),
1557            "1.2.3\n"
1558        );
1559        #[cfg(unix)]
1560        {
1561            use std::os::unix::fs::PermissionsExt;
1562            assert_eq!(
1563                fs::metadata(&destination).unwrap().permissions().mode() & 0o777,
1564                0o755
1565            );
1566        }
1567        fs::remove_dir_all(dir).unwrap();
1568    }
1569
1570    #[test]
1571    fn verified_replace_does_not_downgrade_a_newer_receipted_binary() {
1572        let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1573        fs::create_dir(&dir).unwrap();
1574        let destination = dir.join("mach");
1575        let newer_binary = b"newer verified binary";
1576        fs::write(&destination, newer_binary).unwrap();
1577        let receipt_dir = dir.join(".mach-release-install");
1578        fs::create_dir(&receipt_dir).unwrap();
1579        fs::write(receipt_dir.join(sha256_hex(newer_binary)), b"9.9.9\n").unwrap();
1580
1581        let older_binary = b"older verified binary";
1582        let (installed_version, disposition) = write_verified_binary(
1583            std::io::Cursor::new(older_binary),
1584            &sha256_hex(older_binary),
1585            &destination,
1586            &Version::parse("9.8.7").unwrap(),
1587            None,
1588            |_| {},
1589        )
1590        .unwrap();
1591
1592        assert_eq!(installed_version, Version::parse("9.9.9").unwrap());
1593        assert_eq!(disposition, InstallDisposition::AlreadyCurrent);
1594        assert_eq!(fs::read(&destination).unwrap(), newer_binary);
1595        fs::remove_dir_all(dir).unwrap();
1596    }
1597
1598    #[test]
1599    fn failed_binary_rename_rolls_back_the_candidate_receipt() {
1600        let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1601        fs::create_dir(&dir).unwrap();
1602        let destination = dir.join("mach");
1603        fs::create_dir(&destination).unwrap();
1604        let binary = b"checksum-verified binary";
1605        let digest = sha256_hex(binary);
1606
1607        let error = write_verified_binary(
1608            std::io::Cursor::new(binary),
1609            &digest,
1610            &destination,
1611            &Version::parse("1.2.3").unwrap(),
1612            None,
1613            |_| {},
1614        )
1615        .unwrap_err();
1616
1617        assert!(error.contains("could not replace"));
1618        assert!(!dir.join(RELEASE_RECEIPT_DIR).join(digest).exists());
1619        fs::remove_dir_all(dir).unwrap();
1620    }
1621
1622    #[test]
1623    fn old_install_lock_owner_cannot_remove_a_new_lock() {
1624        let dir = std::env::temp_dir().join(format!("mach-update-lock-{}", uuid::Uuid::new_v4()));
1625        fs::create_dir(&dir).unwrap();
1626        let lock = InstallLock::acquire(&dir).unwrap();
1627        let lock_path = dir.join(INSTALL_LOCK_DIR);
1628        let replacement_record = "0 replacement-owner\n";
1629        fs::write(lock_path.join(INSTALL_LOCK_OWNER), replacement_record).unwrap();
1630
1631        drop(lock);
1632        assert_eq!(
1633            fs::read_to_string(lock_path.join(INSTALL_LOCK_OWNER)).unwrap(),
1634            replacement_record
1635        );
1636
1637        fs::remove_file(lock_path.join(INSTALL_LOCK_OWNER)).unwrap();
1638        fs::remove_dir(lock_path).unwrap();
1639        fs::remove_dir(dir).unwrap();
1640    }
1641
1642    #[test]
1643    fn install_lock_serializes_destination_writers() {
1644        let dir = std::env::temp_dir().join(format!("mach-update-lock-{}", uuid::Uuid::new_v4()));
1645        fs::create_dir(&dir).unwrap();
1646        let first = InstallLock::acquire(&dir).unwrap();
1647        let second_dir = dir.clone();
1648        let (acquired_tx, acquired_rx) = mpsc::channel();
1649        let waiter = std::thread::spawn(move || {
1650            let second = InstallLock::acquire(&second_dir).unwrap();
1651            acquired_tx.send(()).unwrap();
1652            drop(second);
1653        });
1654
1655        assert!(
1656            acquired_rx
1657                .recv_timeout(Duration::from_millis(250))
1658                .is_err(),
1659            "a second installer must wait while the destination lock is held"
1660        );
1661        drop(first);
1662        acquired_rx
1663            .recv_timeout(Duration::from_secs(2))
1664            .expect("the next installer should acquire the released lock");
1665        waiter.join().unwrap();
1666        fs::remove_dir(dir).unwrap();
1667    }
1668
1669    #[test]
1670    fn verified_replace_reports_monotonic_download_progress() {
1671        let dir = std::env::temp_dir().join(format!("mach-update-test-{}", uuid::Uuid::new_v4()));
1672        fs::create_dir(&dir).unwrap();
1673        let destination = dir.join("mach");
1674        let binary = vec![b'x'; 150_000];
1675        let digest = sha256_hex(&binary);
1676        let mut progress = Vec::new();
1677
1678        write_verified_binary(
1679            std::io::Cursor::new(&binary),
1680            &digest,
1681            &destination,
1682            &Version::parse("1.2.3").unwrap(),
1683            Some(binary.len() as u64),
1684            |event| progress.push(event),
1685        )
1686        .unwrap();
1687
1688        assert_eq!(
1689            progress.first(),
1690            Some(&DownloadProgress {
1691                downloaded: 0,
1692                total: Some(binary.len() as u64),
1693            })
1694        );
1695        assert_eq!(
1696            progress.last(),
1697            Some(&DownloadProgress {
1698                downloaded: binary.len() as u64,
1699                total: Some(binary.len() as u64),
1700            })
1701        );
1702        assert!(
1703            progress
1704                .windows(2)
1705                .all(|pair| pair[0].downloaded <= pair[1].downloaded)
1706        );
1707        fs::remove_dir_all(dir).unwrap();
1708    }
1709}