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